diff --git a/app/design/page.module.scss b/app/design/page.module.scss new file mode 100644 index 00000000..55d3d503 --- /dev/null +++ b/app/design/page.module.scss @@ -0,0 +1,589 @@ +@use "breakpoints" as bp; + +// ------------------------------------------------------------------ layout + +.section { + width: 100%; + max-width: 1040px; + display: grid; + gap: 1.25rem; + align-content: start; + justify-items: stretch; + padding-top: 1rem; +} + +.sectionIntro { + font-family: var(--font-body); + font-size: 1rem; + font-style: italic; + color: var(--ink-faded); + max-width: 62ch; +} + +.divider { + width: min(320px, 60%); + color: var(--ink-faded); + opacity: 0.7; +} + +.code { + font-family: var(--font-sans); + font-size: 0.85em; + font-style: normal; + color: var(--ink); + background: rgba(139, 26, 26, 0.08); + border: 1px solid rgba(107, 68, 35, 0.2); + border-radius: 2px; + padding: 0.1em 0.4em; +} + +// The shared small-caps caption idiom used for every token/spec label. +.specLabel { + font-family: var(--font-ui); + font-variant: small-caps; + font-size: 0.85rem; + letter-spacing: 1.5px; + color: var(--ink-faded); +} + +// ------------------------------------------------------------------ colour + +.swatchGrid { + list-style: none; + padding: 0; + margin: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 1rem; +} + +.swatch { + display: grid; + gap: 0.5rem; +} + +.swatchChip { + height: 76px; + border-radius: 2px; + border: 1px solid rgba(61, 40, 23, 0.25); + box-shadow: + inset 0 0 0 1px rgba(248, 236, 208, 0.25), + 0 1px 3px rgba(61, 40, 23, 0.18); +} + +.swatchMeta { + display: grid; + gap: 0.1rem; +} + +.swatchName { + font-family: var(--font-ui); + font-size: 1rem; + color: var(--ink); +} + +.swatchToken, +.swatchHex { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--ink-faded); + letter-spacing: 0.3px; +} + +.swatchHex { + text-transform: uppercase; +} + +// ------------------------------------------------------------- typography + +.typeList { + display: grid; + gap: 1.5rem; +} + +.specimen { + display: grid; + grid-template-columns: minmax(96px, 150px) 1fr; + gap: 1.25rem; + align-items: baseline; + padding-bottom: 1.25rem; + border-bottom: 1px dashed rgba(107, 68, 35, 0.22); + + @media (max-width: bp.$sm) { + grid-template-columns: 1fr; + gap: 0.5rem; + } +} + +// The subtitle primitive carries a bottom border by default; drop it inside +// the type specimen so it does not double the specimen divider. +.specimen :global(.subtitle) { + border-bottom: none; + padding-bottom: 0; + text-align: left; +} + +.fontList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 1.25rem; +} + +.fontRow { + display: grid; + gap: 0.3rem; +} + +.fontSample { + font-size: 1.6rem; + color: var(--ink); + line-height: 1.3; +} + +// --------------------------------------------------------------- spacing + +.spaceList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.85rem; +} + +.spaceRow { + display: grid; + grid-template-columns: minmax(110px, 150px) 1fr auto; + gap: 1rem; + align-items: center; +} + +.spaceBar { + height: 18px; + min-width: 4px; + border-radius: 2px; + background: linear-gradient(90deg, var(--wax-seal), var(--rose-madder)); + justify-self: start; +} + +.spacePx { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--ink-faded); +} + +.spaceDemos { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + padding-top: 0.5rem; +} + +.spaceDemo { + display: grid; + gap: 0.6rem; + justify-items: start; +} + +.padBox { + display: grid; + padding: 1.5rem; + border: 1px dashed rgba(107, 68, 35, 0.4); + border-radius: 2px; + background: rgba(212, 162, 89, 0.12); +} + +.padInner { + display: grid; + min-width: 120px; + height: 40px; + border-radius: 2px; + background: var(--gold-leaf); +} + +.gapRow { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 40px; + justify-content: start; + gap: 1rem; +} + +.gapBox { + height: 40px; + border-radius: 2px; + background: var(--gold-leaf); +} + +// ---------------------------------------------------------------- controls + +.controls { + display: grid; + gap: 2rem; +} + +.group { + display: grid; + gap: 1.1rem; + border: 1px solid rgba(107, 68, 35, 0.25); + border-radius: 3px; + padding: 1.4rem 1.5rem 1.6rem; + background: rgba(248, 236, 208, 0.28); +} + +.groupLegend { + font-family: var(--font-heading); + font-size: 1.05rem; + letter-spacing: 1px; + color: var(--ink-faded); + padding: 0 0.5rem; +} + +.fieldGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 1.4rem; + align-items: start; +} + +.field { + display: grid; + gap: 0.45rem; + align-content: start; +} + +.fieldLabel { + font-family: var(--font-ui); + font-variant: small-caps; + font-size: 0.95rem; + letter-spacing: 1px; + color: var(--ink-faded); +} + +.fieldHint { + font-family: var(--font-sans); + font-size: 0.75rem; + font-style: italic; + color: var(--ink-faded); +} + +// The canonical text control: the same parchment field used by the list +// search input, sized for regular forms. +.control { + appearance: none; + -webkit-appearance: none; + width: 100%; + font-family: var(--font-ui); + font-size: 1rem; + padding: 0.6rem 0.75rem; + background: rgba(248, 236, 208, 0.55); + color: var(--ink); + border: 1px solid rgba(107, 68, 35, 0.35); + border-radius: 2px; + outline: none; + transition: + border-color 150ms ease, + background 150ms ease, + box-shadow 150ms ease; + + &::placeholder { + color: var(--ink-faded); + font-style: italic; + } + + &:focus-visible { + border-color: var(--gold-leaf); + background: rgba(248, 236, 208, 0.9); + box-shadow: 0 0 0 2px rgba(212, 162, 89, 0.18); + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + background: rgba(248, 236, 208, 0.3); + } + + &[readonly] { + background: rgba(248, 236, 208, 0.3); + color: var(--ink-faded); + } + + &[aria-invalid="true"] { + border-color: var(--wax-seal); + box-shadow: 0 0 0 2px rgba(139, 26, 26, 0.14); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +// A select needs the hand-drawn chevron the native arrow can't match. +select.control { + cursor: pointer; + padding-right: 2rem; + background-image: + linear-gradient(45deg, transparent 50%, rgba(107, 68, 35, 0.55) 50%), + linear-gradient(135deg, rgba(107, 68, 35, 0.55) 50%, transparent 50%); + background-position: + calc(100% - 1.1rem) 55%, + calc(100% - 0.8rem) 55%; + background-size: + 0.32rem 0.32rem, + 0.32rem 0.32rem; + background-repeat: no-repeat; +} + +.textarea { + @extend .control; + font-family: var(--font-body); + font-size: 1.05rem; + line-height: 1.5; + min-height: 6rem; + resize: vertical; +} + +.multi { + @extend .control; + padding: 0.4rem; + cursor: pointer; + + option { + padding: 0.35rem 0.5rem; + border-radius: 2px; + } + + option:checked { + background: rgba(212, 162, 89, 0.4); + color: var(--ink); + } +} + +.range { + width: 100%; + accent-color: var(--gold-leaf); + cursor: pointer; +} + +.colorInput { + width: 100%; + height: 3rem; + padding: 0.2rem; + background: rgba(248, 236, 208, 0.55); + border: 1px solid rgba(107, 68, 35, 0.35); + border-radius: 2px; + cursor: pointer; + + &:focus-visible { + border-color: var(--gold-leaf); + outline: none; + box-shadow: 0 0 0 2px rgba(212, 162, 89, 0.18); + } +} + +.file { + width: 100%; + font-family: var(--font-sans); + font-size: 0.85rem; + color: var(--ink-faded); + + &::file-selector-button { + font-family: var(--font-ui); + font-size: 0.85rem; + letter-spacing: 0.5px; + margin-right: 0.75rem; + padding: 0.45rem 0.9rem; + background: rgba(248, 236, 208, 0.6); + color: var(--ink); + border: 1px solid rgba(107, 68, 35, 0.35); + border-radius: 2px; + cursor: pointer; + transition: + border-color 120ms ease, + color 120ms ease; + } + + &::file-selector-button:hover { + border-color: var(--gold-leaf); + color: var(--gold-leaf); + } +} + +// ---------------------------------------------------- checkbox / radio sets + +.optionSet { + display: grid; + gap: 0.65rem; + border: 1px solid rgba(107, 68, 35, 0.3); + border-radius: 2px; + padding: 1rem 1.2rem 1.15rem; + background: rgba(248, 236, 208, 0.4); +} + +.optionLegend { + font-family: var(--font-ui); + font-variant: small-caps; + font-size: 0.9rem; + letter-spacing: 1px; + color: var(--ink-faded); + padding: 0 0.4rem; +} + +.option { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.6rem; + align-items: center; + font-family: var(--font-body); + font-size: 1.05rem; + color: var(--ink); + cursor: pointer; +} + +.check { + width: 1.15rem; + height: 1.15rem; + accent-color: var(--gold-leaf); + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--gold-leaf); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------- switch + +.switch { + display: inline-grid; + grid-template-columns: auto 1fr; + gap: 0.75rem; + align-items: center; + cursor: pointer; + font-family: var(--font-body); + font-size: 1.05rem; + color: var(--ink); +} + +// Visually hidden but focusable and in the tab order; the track/thumb render +// its state via the sibling combinator. +.switchInput { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.switchTrack { + display: grid; + align-items: center; + width: 46px; + height: 26px; + padding: 2px; + border-radius: 13px; + border: 1px solid rgba(107, 68, 35, 0.35); + background: rgba(107, 68, 35, 0.28); + transition: + background 150ms ease, + box-shadow 150ms ease; +} + +.switchThumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--vellum); + box-shadow: 0 1px 2px rgba(61, 40, 23, 0.4); + transform: translateX(0); + transition: transform 150ms ease; +} + +.switchInput:checked ~ .switchTrack { + background: var(--gold-leaf); +} + +.switchInput:checked ~ .switchTrack .switchThumb { + transform: translateX(20px); +} + +.switchInput:focus-visible ~ .switchTrack { + border-color: var(--gold-leaf); + box-shadow: 0 0 0 2px rgba(212, 162, 89, 0.35); +} + +@media (prefers-reduced-motion: reduce) { + .switchTrack, + .switchThumb { + transition: none; + } +} + +// --------------------------------------------------------------- buttons + +.btnRow { + display: grid; + grid-auto-flow: column; + grid-auto-columns: max-content; + justify-content: start; + gap: 0.75rem; + + @media (max-width: bp.$sm) { + grid-auto-flow: row; + grid-auto-columns: 1fr; + } +} + +.btn { + appearance: none; + font-family: var(--font-ui); + font-size: 0.95rem; + letter-spacing: 0.5px; + padding: 0.6rem 1.3rem; + border-radius: 2px; + border: 1px solid rgba(107, 68, 35, 0.35); + background: rgba(248, 236, 208, 0.6); + color: var(--ink); + cursor: pointer; + transition: + border-color 120ms ease, + background 120ms ease, + color 120ms ease; + + &:hover:not(:disabled), + &:focus-visible:not(:disabled) { + border-color: var(--gold-leaf); + color: var(--gold-leaf); + outline: none; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +} + +.btnPrimary { + @extend .btn; + background: var(--wax-seal); + border-color: var(--wax-seal); + color: var(--vellum); + + &:hover:not(:disabled), + &:focus-visible:not(:disabled) { + color: var(--vellum); + border-color: var(--wax-seal); + filter: brightness(1.12); + box-shadow: 0 0 0 2px rgba(212, 162, 89, 0.3); + } +} + +.btnGhost { + @extend .btn; + background: transparent; +} diff --git a/app/design/page.tsx b/app/design/page.tsx new file mode 100644 index 00000000..333754ab --- /dev/null +++ b/app/design/page.tsx @@ -0,0 +1,664 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { ParchmentLayout } from "@/components/ParchmentLayout"; +import { PageHeading } from "@/components/PageHeading"; +import { FiligreeRule } from "@/components/Filigree"; +import styles from "@/app/design/page.module.scss"; + +export const metadata: Metadata = { + title: "Design System · Atlas of the Known World", + description: + "The visual grammar of the Known World: type, colour, spacing, and the full roll of form controls.", +}; + +type Swatch = { token: string; hex: string; name: string }; + +// The core palette and regional heraldic tints, mirrored from the design +// tokens declared in `styles/globals.scss`. This page is the living catalogue +// of those tokens; keep it in step when a token is added or renamed there. +const CORE_PALETTE: Swatch[] = [ + { token: "--parchment-light", hex: "#f4e4c1", name: "Parchment Light" }, + { token: "--parchment-dark", hex: "#e8d3a0", name: "Parchment Dark" }, + { token: "--vellum", hex: "#f8ecd0", name: "Vellum" }, + { token: "--ink", hex: "#3d2817", name: "Ink" }, + { token: "--ink-faded", hex: "#6b4423", name: "Ink Faded" }, + { token: "--ink-slate", hex: "#2e3a55", name: "Ink Slate" }, + { token: "--ink-rose", hex: "#6a3142", name: "Ink Rose" }, + { token: "--wax-seal", hex: "#8b1a1a", name: "Wax Seal" }, + { token: "--gold-leaf", hex: "#d4a259", name: "Gold Leaf" }, + { token: "--rose-madder", hex: "#a85a6b", name: "Rose Madder" }, +]; + +const REGION_PALETTE: Swatch[] = [ + { token: "--region-color-north", hex: "#5d6f7f", name: "North" }, + { token: "--region-color-vale", hex: "#4d7caa", name: "Vale" }, + { token: "--region-color-riverlands", hex: "#3d5a7c", name: "Riverlands" }, + { token: "--region-color-westerlands", hex: "#b13a3a", name: "Westerlands" }, + { token: "--region-color-reach", hex: "#6f8336", name: "Reach" }, + { token: "--region-color-stormlands", hex: "#c89a31", name: "Stormlands" }, + { token: "--region-color-dorne", hex: "#c7693a", name: "Dorne" }, + { + token: "--region-color-iron-islands", + hex: "#2f4a44", + name: "Iron Islands", + }, + { token: "--region-color-crownlands", hex: "#5a2a30", name: "Crownlands" }, +]; + +type FontRole = { family: string; label: string; sample: string }; + +const FONT_ROLES: FontRole[] = [ + { + family: "var(--font-heading)", + label: "Cinzel · Headings", + sample: "The Known World", + }, + { + family: "var(--font-character-name)", + label: "Cormorant Unicase · Character names", + sample: "Eddard Stark", + }, + { + family: "var(--font-body)", + label: "EB Garamond · Body & UI", + sample: "The night is dark and full of terrors.", + }, + { + family: "var(--font-sans)", + label: "Alegreya Sans · Data & captions", + sample: "298 AC · 42 houses · 9 regions", + }, +]; + +type Space = { step: string; rem: string; px: string }; + +// The de facto spacing ramp used across the modules (gaps and padding are +// authored in these rem steps). Space and align with `gap`/`padding`, never +// with margins. +const SPACING: Space[] = [ + { step: "2xs", rem: "0.25rem", px: "4px" }, + { step: "xs", rem: "0.5rem", px: "8px" }, + { step: "sm", rem: "0.75rem", px: "12px" }, + { step: "md", rem: "1rem", px: "16px" }, + { step: "lg", rem: "1.5rem", px: "24px" }, + { step: "xl", rem: "2rem", px: "32px" }, + { step: "2xl", rem: "3rem", px: "48px" }, +]; + +type InputField = { + id: string; + label: string; + type: string; + placeholder?: string; + defaultValue?: string; + autoComplete?: string; + min?: number; + max?: number; + step?: number; + hint?: string; +}; + +const TEXT_FIELDS: InputField[] = [ + { + id: "df-text", + label: "Text", + type: "text", + placeholder: "House Stark of Winterfell", + }, + { + id: "df-search", + label: "Search", + type: "search", + placeholder: "Search the rolls…", + }, + { + id: "df-email", + label: "Email", + type: "email", + placeholder: "maester@citadel.oldtown", + autoComplete: "email", + }, + { + id: "df-password", + label: "Password", + type: "password", + defaultValue: "valarmorghulis", + autoComplete: "current-password", + }, + { + id: "df-number", + label: "Number", + type: "number", + defaultValue: "300", + min: 0, + max: 8000, + step: 10, + hint: "The Wall stands 700 feet tall.", + }, + { id: "df-tel", label: "Telephone", type: "tel", placeholder: "000-RAVEN" }, + { + id: "df-url", + label: "URL", + type: "url", + placeholder: "https://citadel.oldtown", + }, +]; + +const DATETIME_FIELDS: InputField[] = [ + { id: "df-date", label: "Date", type: "date", defaultValue: "0298-04-17" }, + { id: "df-time", label: "Time", type: "time", defaultValue: "07:30" }, + { + id: "df-datetime", + label: "Date & time", + type: "datetime-local", + defaultValue: "0298-04-17T07:30", + }, + { id: "df-month", label: "Month", type: "month", defaultValue: "0298-04" }, + { id: "df-week", label: "Week", type: "week", defaultValue: "0298-W16" }, +]; + +const CHECKBOXES: { id: string; label: string; checked?: boolean }[] = [ + { id: "df-map-castles", label: "Castles", checked: true }, + { id: "df-map-battles", label: "Battle sites" }, + { id: "df-map-roads", label: "Kingsroad & rivers" }, + { id: "df-map-regions", label: "Regional borders", checked: true }, + { id: "df-map-ruins", label: "Ruins" }, +]; + +const RADIOS: { id: string; label: string; checked?: boolean }[] = [ + { id: "df-sort-name", label: "Name", checked: true }, + { id: "df-sort-region", label: "Region" }, + { id: "df-sort-rank", label: "Rank" }, + { id: "df-sort-year", label: "Founding year" }, +]; + +const MULTI_OPTIONS: string[] = [ + "Winterfell", + "Casterly Rock", + "Storm's End", + "Highgarden", + "Sunspear", + "The Eyrie", + "Dragonstone", +]; + +function Field({ + id, + label, + hint, + children, +}: { + id: string; + label: string; + hint?: string; + children: ReactNode; +}) { + return ( +
+ + {children} + {!!hint && {hint}} +
+ ); +} + +export default function DesignSystemPage() { + return ( + + + + {/* ---------------------------------------------------------------- */} +
+

Colour

+

+ Every hue is a CSS custom property declared in{" "} + styles/globals.scss. Reference + the token, never the raw hex. +

+ +

Core palette

+
    + {CORE_PALETTE.map((s) => ( +
  • +
  • + ))} +
+ +

Regional tints

+
    + {REGION_PALETTE.map((s) => ( +
  • +
  • + ))} +
+
+ + + + {/* ---------------------------------------------------------------- */} +
+

Typography

+

+ Headings are set in Cinzel; body copy in EB Garamond. The scale below + renders each element exactly as the global stylesheet styles it. +

+ +
+
+ H1 · 2.2rem +

Winter Is Coming

+
+
+ H2 · 1.4rem +

The Great Houses

+
+
+ H3 · 1.15rem +

Wardens of the North

+
+
+ H4 +

Sworn Bannermen

+
+
+ H5 +
Hedge Knights
+
+
+ Body · 1.25rem +

+ A maester forges his chain in the Citadel of Oldtown, each link a + different metal for a different art. He keeps the ravens, tends + the sick, and writes the histories that outlast the lords he + serves. +

+
+
+ Emphasis +

+ Words can carry bold weight or an{" "} + italic aside, and a{" "} + cross-reference links onward in ink + and gold. +

+
+
+ Lede +

+ An italic lede, small and faded, sits beneath the title. +

+
+
+ +

Type roles

+
    + {FONT_ROLES.map((f) => ( +
  • + {f.label} + + {f.sample} + +
  • + ))} +
+
+ + + + {/* ---------------------------------------------------------------- */} +
+

Spacing & rhythm

+

+ One ramp of rem steps drives every gap and pad. Space items with{" "} + gap and{" "} + padding; margins are avoided. +

+ +
    + {SPACING.map((s) => ( +
  • + + {s.step} · {s.rem} + + + {s.px} +
  • + ))} +
+ +
+
+ padding: 1.5rem + + + +
+
+ gap: 1rem + + + + + + +
+
+
+ + + + {/* ---------------------------------------------------------------- */} +
+

Form controls

+

+ Every native control, dressed in parchment and ink. Try them: the + fields hold their own state, and Reset fields restores the + defaults. +

+ +
+
+ Text fields +
+ {TEXT_FIELDS.map((f) => ( + + + + ))} +
+
+ +
+ Date & time +
+ {DATETIME_FIELDS.map((f) => ( + + + + ))} +
+
+ +
+ Range, colour & file +
+ + + + + + + + + +
+
+ +
+ Long-form text +
+ +