-
+ }
+ />
{book.title}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 3a61561..b4e0ba7 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -1,58 +1,116 @@
/* ---------------------------------------------------------------------------
- Design tokens. Light is the default; dark is a token swap so every component
- below is written once. The server-rendered pages in
+ Direction C - Spine. "The stacks and the desk."
+
+ Two zones, one product. The *desk* is the light working surface: tables,
+ forms, modals, settings. The *stacks* is the dark buckram-green zone: the
+ navigation everywhere, and the shelf on /books.
+
+ Both are expressed in the same token names, so every component below is
+ written once and comes out right in either zone. Light is the default and
+ dark is a token swap; `.stacks` is a scope, not a theme.
+
+ The design, and the reasoning behind it:
+ ../../docs/superpowers/specs/2026-08-22-direction-c-spine.md
+
+ The server-rendered pages in
../../Library-Management-System-Version-2/src/main/resources/static/css/pages.css
- use the same palette so the two halves of the app look like one product.
+ carry the same palette, so the two halves of the app look like one product.
--------------------------------------------------------------------------- */
:root {
- --bg: #f5f6f8;
- --surface: #ffffff;
- --surface-2: #f0f2f5;
- --border: #e3e6ea;
- --text: #171a1f;
- --muted: #667085;
- --accent: #2f5fd0;
- --accent-hover: #2850b4;
- --accent-soft: #e8eefc;
- --accent-contrast: #ffffff;
- --ok-bg: #e6f5ec;
- --ok-text: #16794c;
- --warn-bg: #fdf1dc;
- --warn-text: #9a6100;
- --danger-bg: #fdecea;
- --danger-text: #b4291f;
- --ring: rgba(47, 95, 208, 0.35);
- --shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.05);
- --shadow-md: 0 8px 24px rgba(16, 24, 40, 0.09);
- --glow: rgba(47, 95, 208, 0.13);
+ /* The desk. Paper, not white: #ffffff next to buckram reads as a hole. */
+ --bg: #e9ece6;
+ --surface: #f4f5f2;
+ --surface-2: #e2e6de;
+ --border: #d5d9cf;
+ --text: #191c1a;
+ --muted: #5c665f;
+ --accent: #1d4235;
+ --accent-hover: #16342a;
+ --accent-soft: #dde7e0;
+ --accent-contrast: #f4f5f2;
+ --ok-bg: #dde7e0;
+ --ok-text: #1d4235;
+ --warn-bg: #f2e8d8;
+ --warn-text: #7a5c3e;
+ --danger-bg: #f8e2e0;
+ --danger-text: #b4322a;
+ --ring: rgba(29, 66, 53, 0.42);
+ --shadow-sm: 0 1px 2px rgba(24, 36, 30, 0.07);
+ --shadow-md: 0 10px 28px rgba(24, 36, 30, 0.13);
+ --glow: rgba(29, 66, 53, 0.1);
+
+ /* The stacks. Fixed points: these do not swap with the colour scheme, because
+ the dark zone is a chosen material rather than an absence of light. */
+ --stacks: #18241e;
+ --stacks-2: #1e2b24;
+ --stacks-line: rgba(232, 230, 222, 0.14);
+ --shelf-light: #e8e6de;
+
+ /* The only saturated colour in the product. Spend it on one thing. */
+ --overdue: #b4322a;
+
+ /* Type - three roles, no fourth. Display carries the voice, body carries the
+ prose, mono carries anything a machine wrote: ISBNs, dates, counts, stamps. */
+ --font-display: 'Fraunces Variable', Georgia, 'Times New Roman', serif;
+ --font-body: 'Public Sans Variable', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
+ --display-axes: 'opsz' 120, 'wght' 500, 'SOFT' 40, 'WONK' 1;
}
@media (prefers-color-scheme: dark) {
:root {
- --bg: #101216;
- --surface: #191c22;
- --surface-2: #21252d;
- --border: #2b303a;
- --text: #e9ebef;
- --muted: #9aa2ae;
- --accent: #7aa2f7;
- --accent-hover: #93b4ff;
- --accent-soft: #1d2941;
- --accent-contrast: #101216;
- --ok-bg: #16301f;
- --ok-text: #6ed08d;
- --warn-bg: #33270f;
- --warn-text: #e3b341;
- --danger-bg: #341b1a;
- --danger-text: #f08a80;
- --ring: rgba(122, 162, 247, 0.4);
- --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
- --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45);
- --glow: rgba(122, 162, 247, 0.1);
+ /* The desk after hours - still the desk, still distinct from the stacks. */
+ --bg: #101613;
+ --surface: #171f1b;
+ --surface-2: #1e2a24;
+ --border: #2a352f;
+ --text: #e8e6de;
+ --muted: #98a49c;
+ --accent: #8cc0a5;
+ --accent-hover: #a5d3ba;
+ --accent-soft: #1c2f27;
+ --accent-contrast: #101613;
+ --ok-bg: #1a2f25;
+ --ok-text: #8cc0a5;
+ --warn-bg: #2e2618;
+ --warn-text: #d3ac7a;
+ --danger-bg: #33191a;
+ --danger-text: #e8867d;
+ --ring: rgba(140, 192, 165, 0.45);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
+ --shadow-md: 0 10px 28px rgba(0, 0, 0, 0.5);
+ --glow: rgba(140, 192, 165, 0.12);
}
}
+/*
+ * The stacks, as a scope. Anything rendered inside - a button, the search box,
+ * the empty state - reads the same token names and comes out dark, so none of
+ * those components needs a second, darker copy of itself.
+ *
+ * The navigation is always in the stacks. It is the same bar in every room, and
+ * it is what makes the desk below it read as a surface rather than the page.
+ */
+.stacks,
+.nav {
+ --surface: #1e2b24;
+ --surface-2: #24322a;
+ --border: rgba(232, 230, 222, 0.14);
+ --text: #e8e6de;
+ --muted: rgba(232, 230, 222, 0.6);
+ --accent: #a9cdb9;
+ --accent-hover: #c2ddcf;
+ --accent-soft: rgba(232, 230, 222, 0.1);
+ --accent-contrast: #18241e;
+ --ring: rgba(232, 230, 222, 0.55);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.45);
+ --shadow-md: 0 12px 30px rgba(0, 0, 0, 0.55);
+
+ background: var(--stacks);
+ color: var(--text);
+}
+
* {
box-sizing: border-box;
}
@@ -61,14 +119,20 @@ body {
margin: 0;
background: var(--bg);
color: var(--text);
- font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+ font: 400 15px/1.55 var(--font-body);
+ /* Three variable faces, every weight real. Nothing here needs faking. */
+ font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3 {
- letter-spacing: -0.015em;
+ font-family: var(--font-display);
+ font-variation-settings: var(--display-axes);
+ font-weight: 500;
+ letter-spacing: -0.012em;
+ line-height: 1.05;
}
a {
@@ -80,6 +144,23 @@ a {
outline-offset: 2px;
}
+/*
+ * Read aloud, never shown. The shelf leans on this: a spine's visible text is
+ * its title turned ninety degrees, and everything else a screen reader needs -
+ * the author, whether the book is out, when it is due - rides along here.
+ */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
/* --------------------------------- shell --------------------------------- */
.app {
@@ -92,9 +173,10 @@ a {
position: sticky;
top: 0;
z-index: 10;
- background: color-mix(in srgb, var(--surface) 88%, transparent);
+ /* Slightly transparent so the shelf scrolls up behind it, still unmistakably buckram. */
+ background: color-mix(in srgb, var(--stacks) 92%, transparent);
backdrop-filter: blur(8px);
- border-bottom: 1px solid var(--border);
+ border-bottom: 1px solid var(--stacks-line);
}
.nav-inner {
@@ -112,7 +194,13 @@ a {
display: flex;
align-items: center;
gap: 0.55rem;
- font-weight: 650;
+ /* The wordmark is the display face at reading size, so it takes the optical
+ size meant for it rather than the headline setting shrunk down. */
+ font-family: var(--font-display);
+ font-variation-settings: 'opsz' 24, 'wght' 600, 'SOFT' 30, 'WONK' 1;
+ font-size: 1.05rem;
+ font-weight: 600;
+ letter-spacing: -0.005em;
}
.brand-mark {
@@ -121,8 +209,9 @@ a {
border-radius: 8px;
display: grid;
place-items: center;
- background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 60%, #9b6cf0));
- color: #fff;
+ /* On the stacks the only signal is light, so the mark is lit rather than coloured. */
+ background: rgba(232, 230, 222, 0.12);
+ color: var(--shelf-light);
font-size: 0.85rem;
}
@@ -196,7 +285,11 @@ a {
width: 100%;
max-width: 1080px;
margin: 0 auto;
- padding: 2rem 1.5rem 3rem;
+ /* Published as tokens so a full-bleed child - the stacks zone - can cancel exactly
+ this inset instead of guessing at it and hanging off the side of the page. */
+ --content-pad-x: 1.5rem;
+ --content-pad-top: 2rem;
+ padding: var(--content-pad-top) var(--content-pad-x) 3rem;
}
/* --------------------------------- pages --------------------------------- */
@@ -451,6 +544,8 @@ select:focus-visible {
color: var(--text);
font: inherit;
font-weight: 500;
+ /* Some of these are links wearing a button - "Discover more" is one. A button is not underlined. */
+ text-decoration: none;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, border-color 0.12s ease;
@@ -566,14 +661,18 @@ select:focus-visible {
/* ---------------------------------- auth --------------------------------- */
+/*
+ * Signing in happens at the door, so the page is the stacks and the card is a piece of the desk
+ * brought out to it. The single light above is the only thing on this screen that is not flat.
+ */
.auth {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
- radial-gradient(60rem 30rem at 50% -10rem, var(--glow), transparent 70%),
- var(--bg);
+ radial-gradient(60rem 30rem at 50% -12rem, rgba(232, 230, 222, 0.09), transparent 70%),
+ var(--stacks);
}
.auth-card {
@@ -589,10 +688,19 @@ select:focus-visible {
display: flex;
align-items: center;
gap: 0.55rem;
- font-weight: 650;
+ font-family: var(--font-display);
+ font-variation-settings: 'opsz' 24, 'wght' 600, 'SOFT' 30, 'WONK' 1;
+ font-size: 1.1rem;
+ font-weight: 600;
margin-bottom: 1.25rem;
}
+/* The card is desk, so its brand mark is inked rather than lit. */
+.auth-brand .brand-mark {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
.auth-card h1 {
margin: 0;
font-size: 1.45rem;
@@ -638,7 +746,7 @@ select:focus-visible {
}
.mono {
- font-family: ui-monospace, 'Cascadia Code', 'SF Mono', monospace;
+ font-family: var(--font-mono);
font-size: 0.86em;
}
@@ -656,7 +764,9 @@ select:focus-visible {
}
.content {
- padding: 1.5rem 1rem 2.5rem;
+ --content-pad-x: 1rem;
+ --content-pad-top: 1.5rem;
+ padding: var(--content-pad-top) var(--content-pad-x) 2.5rem;
}
.user-name {
@@ -706,7 +816,8 @@ select:focus-visible {
align-items: center;
justify-content: center;
padding: 1.5rem;
- background: rgba(16, 24, 40, 0.45);
+ /* The room going dark behind the panel: buckram, not the old blue-grey. */
+ background: rgba(16, 26, 21, 0.5);
backdrop-filter: blur(2px);
}
@@ -1469,8 +1580,10 @@ button.user-chip[aria-expanded='true'] {
/* Tabular figures so the four tiles do not jitter as the numbers gain digits. */
.stat-value {
- font-size: 1.75rem;
- font-weight: 650;
+ /* A figure is something a machine counted, so it is set in the data face. */
+ font-family: var(--font-mono);
+ font-size: 1.6rem;
+ font-weight: 600;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
@@ -1596,3 +1709,318 @@ button.user-chip[aria-expanded='true'] {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
+
+/* ---------------------------------------------------------------------------
+ The shelf. The signature of direction C.
+
+ A borrowed book is not a spine wearing a status pill - it is a gap, with its
+ due date stamped down it. Everything else here stays quiet to pay for that.
+
+ The vertical text is a CSS treatment only: it is real text, so it stays
+ selectable, findable with ctrl-F and readable in order.
+ --------------------------------------------------------------------------- */
+
+.shelf-board {
+ background: var(--stacks);
+ border-radius: 10px;
+ padding: 1.4rem 1.25rem 0;
+}
+
+/* Nothing to shelve: the board stays, so an empty shelf reads as an empty shelf. */
+.shelf-board.is-bare {
+ padding-bottom: 1.4rem;
+}
+
+.shelf {
+ display: flex;
+ align-items: flex-end;
+ gap: 6px;
+ min-height: 122px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ /* The shelf itself: everything above stands on this line. */
+ border-bottom: 3px solid rgba(0, 0, 0, 0.6);
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+}
+
+.shelf > li {
+ display: flex;
+ align-items: flex-end;
+ flex: none;
+}
+
+.spine {
+ position: relative;
+ flex: none;
+ width: 26px;
+ height: var(--spine-height, 72px);
+ margin: 0;
+ padding: 0;
+ border: 0;
+ border-radius: 2px 2px 0 0;
+ background: var(--cloth, #5b5f3d);
+ color: rgba(255, 255, 255, 0.86);
+ font: inherit;
+ cursor: pointer;
+ /* The light falls from the left, so every spine carries its neighbour's shadow. */
+ box-shadow:
+ inset -3px 0 6px rgba(0, 0, 0, 0.28),
+ 0 -1px 0 rgba(255, 255, 255, 0.12);
+ transition:
+ transform 140ms ease,
+ width 220ms ease,
+ height 220ms ease,
+ box-shadow 140ms ease;
+}
+
+/*
+ * Turned ninety degrees and read bottom-up, the way a spine on a shelf is. The line-height matches
+ * the spine's width, which is what centres the text across it.
+ */
+.spine-title,
+.spine-due {
+ position: absolute;
+ right: 0;
+ bottom: 8px;
+ left: 0;
+ height: calc(100% - 16px);
+ overflow: hidden;
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ text-align: left;
+ white-space: nowrap;
+}
+
+.spine-title {
+ font-size: 8.5px;
+ font-weight: 600;
+ line-height: 26px;
+ letter-spacing: 0.05em;
+}
+
+/* A gap: the book is not here. Recessed, unlit, and stamped with the date it comes back. */
+.spine.is-out {
+ background: rgba(0, 0, 0, 0.45);
+ color: rgba(232, 230, 222, 0.5);
+ box-shadow: inset 2px 3px 9px rgba(0, 0, 0, 0.65);
+}
+
+.spine-due {
+ font-family: var(--font-mono);
+ font-size: 7.5px;
+ font-weight: 500;
+ line-height: 26px;
+ letter-spacing: 0.1em;
+}
+
+/*
+ * The one moment of motion: a spine lifts and catches the light. Focus does exactly what hover
+ * does, so a keyboard user and a mouse user are looking at the same thing.
+ */
+.spine:hover,
+.spine:focus-visible {
+ transform: translateY(-14px);
+ box-shadow:
+ inset -3px 0 6px rgba(0, 0, 0, 0.28),
+ 0 12px 22px rgba(0, 0, 0, 0.55),
+ 0 0 0 1px rgba(255, 255, 255, 0.22);
+}
+
+.spine:focus-visible {
+ outline: 2px solid var(--shelf-light);
+ outline-offset: 3px;
+}
+
+/* A gap has nothing to lift, so selecting one lights it rather than moving it. */
+.spine.is-out:hover,
+.spine.is-out:focus-visible {
+ transform: none;
+ box-shadow:
+ inset 2px 3px 9px rgba(0, 0, 0, 0.65),
+ 0 0 0 1px rgba(255, 255, 255, 0.18);
+}
+
+.spine.is-out.is-selected {
+ box-shadow:
+ inset 2px 3px 9px rgba(0, 0, 0, 0.65),
+ 0 0 0 1px rgba(232, 230, 222, 0.45);
+}
+
+/* Turned face-out. Only a book that is actually on the shelf can be. */
+.spine.is-selected:not(.is-out) {
+ width: 74px;
+ height: 112px;
+ transform: none;
+ box-shadow:
+ 0 12px 28px rgba(0, 0, 0, 0.6),
+ 0 0 0 1px rgba(255, 255, 255, 0.1);
+}
+
+.spine-face {
+ position: absolute;
+ inset: 0;
+ display: block;
+ overflow: hidden;
+ border-radius: 2px;
+}
+
+.spine-face-art {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+/* Sits over the cover, and over the bare cloth when no cover ever arrives. */
+.spine-face-plate {
+ position: absolute;
+ inset: auto 0 0 0;
+ display: block;
+ padding: 6px 7px;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.82));
+ color: #fff;
+ font-size: 8.5px;
+ line-height: 1.3;
+ text-align: left;
+}
+
+.spine-face-plate strong {
+ display: block;
+ font-weight: 600;
+}
+
+.spine-face-plate em {
+ display: block;
+ font-style: normal;
+ opacity: 0.75;
+}
+
+/* Waiting for a page. Bare slots at the usual heights - nothing pulses, nothing slides in. */
+.spine.is-loading {
+ display: block;
+ flex: none;
+ width: 26px;
+ height: var(--spine-height, 72px);
+ border-radius: 2px 2px 0 0;
+ background: rgba(232, 230, 222, 0.09);
+}
+
+/*
+ * A shelf is a horizontal thing to scan, so below 44px per spine it scrolls rather than squeezing.
+ * Titles get more room that way, not less, and there is deliberately no second list underneath -
+ * duplicating the shelf as rows is what would turn it back into decoration.
+ */
+@media (max-width: 720px) {
+ .shelf {
+ scroll-snap-type: x mandatory;
+ }
+
+ .spine,
+ .spine.is-loading {
+ width: 44px;
+ scroll-snap-align: center;
+ }
+
+ .spine-title,
+ .spine-due {
+ line-height: 44px;
+ }
+
+ .spine-title {
+ font-size: 10px;
+ }
+
+ .spine-due {
+ font-size: 8.5px;
+ }
+
+ .spine.is-selected:not(.is-out) {
+ width: 96px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .spine {
+ transition: none;
+ }
+
+ /* The lift becomes an outline, and the turn becomes an appearance rather than a movement. */
+ .spine:hover,
+ .spine:focus-visible {
+ transform: none;
+ outline: 2px solid var(--shelf-light);
+ outline-offset: 2px;
+ }
+}
+
+/* ----------------------------- the stacks zone ----------------------------
+ Where a page leaves the desk. Pulled out to the edges of the content column
+ so it reads as a place you have walked into rather than a card sitting on
+ the desk - which on a narrow screen means the window edges.
+ -------------------------------------------------------------------------- */
+
+.stacks-zone {
+ margin:
+ calc(var(--content-pad-top, 2rem) * -1)
+ calc(var(--content-pad-x, 1.5rem) * -1)
+ 1.75rem;
+ padding: var(--content-pad-top, 2rem) var(--content-pad-x, 1.5rem) 1.75rem;
+ border-radius: 0 0 14px 14px;
+}
+
+.stacks-zone .page-head h2 {
+ font-size: 2.3rem;
+}
+
+/* The line above a page head: what this is, counted. Set in the data face. */
+.eyebrow {
+ margin: 0 0 0.5rem;
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-weight: 500;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+/* Shelf or list. Not a mode - the same catalogue, looked at two ways. */
+.view-toggle {
+ display: inline-flex;
+ gap: 2px;
+ padding: 3px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface-2);
+}
+
+.view-toggle button {
+ padding: 0.35rem 0.85rem;
+ border: 0;
+ border-radius: 999px;
+ background: none;
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.85rem;
+ cursor: pointer;
+ transition: background 0.12s ease, color 0.12s ease;
+}
+
+.view-toggle button:hover {
+ color: var(--text);
+}
+
+.view-toggle button.is-current {
+ background: var(--surface);
+ color: var(--text);
+ font-weight: 600;
+ box-shadow: var(--shadow-sm);
+}
+
+/* On the board rather than in a card: the empty state is the bare shelf itself. */
+.shelf-board.is-bare .empty {
+ border: 0;
+ background: none;
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
index bc17c40..e4993eb 100644
--- a/frontend/tsconfig.node.json
+++ b/frontend/tsconfig.node.json
@@ -21,5 +21,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
- "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e"]
+ "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e", "design"]
}
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 5f1e356..ce549b8 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -10,6 +10,8 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
- include: ['src/**/*.test.{ts,tsx}'],
+ // design/ holds the stylesheet contract test, which reads styles.css off disk and so runs as
+ // Node code rather than browser code. See design/tokens.test.ts.
+ include: ['src/**/*.test.{ts,tsx}', 'design/**/*.test.ts'],
},
})