🌾 docs: give the page the identity its name implies

The library is called cereale and the page was generic dark-developer-docs
with an emoji in the brand mark. Three directions were drafted and scored by
separate judges on identity, legibility and craft; this is the disciplined
one with the two grafts that fixed its own weaknesses.

Palette. Indigo on off-white becomes rye-brown on oat paper, warm in both
themes. The accent is deliberately NOT wheat-gold: gold is both the cliche
and a collision with --warn, which already owns amber, and two amber families
would make "our brand" and "needs your attention" the same colour.

One ornament, used three times. A 2px mark repeated every 9px: as a rule
above each section head, stood on end as the stalk beside each note, and in
place of the three fake macOS traffic lights on every code panel — which were
the most generic pixels on the page. The brand emoji and the emoji favicon
both go, replaced by the same figure drawn as a five-rect SVG: an ear of grain
reduced to its skeleton, which is the section rule stood upright. An emoji is
a different picture on every OS, so it is the one brand element you do not
control.

Grafted from the two directions that lost:

- Ruled links. On a palette this warm, --accent-text and --text-muted sit
  1.14:1 apart, so colour alone loses a link in prose. Links now keep body
  colour and carry a 2px accent rule, which satisfies 1.4.1 outright.
- --accent-on-code. #editor:focus drew its ring in --accent against the
  always-dark slab: 2.86:1, a live 1.4.11 failure on the shipped page. The
  new single-valued token measures 7.03:1.
- --border-ui, for icon-only and outlined controls, which were on
  --border-strong at 1.60:1. Now 3.54:1.
- --err-line/--err-text, replacing three loose literals.

Two bugs found by rendering rather than by reading. The header overflowed the
viewport by 63px across the whole 641-767px band, hidden rather than fixed by
body{overflow-x:hidden}; the Size link added in the previous commit widened
that to 109px. The nav-link breakpoint moves 640px to 860px, which is where
they actually fit.

Verified: 72 foreground/background pairs computed in both themes, none below
4.5:1 for text or 3:1 for non-text; no horizontal overflow at any of 20
widths from 320 to 1920; no page errors and no external requests in either
theme; the playground still compiles and runs; markup parses with nothing
unclosed; check:docs passes and the generated files stay in sync.

Not taken: recolouring --t-decorator to the brand. It is the best identity
idea in the set — the decorators are what the library is — but the six --t-*
tokens are a shared vocabulary, page.js emits their class names, and the
playground highlights code the reader pastes in. One token, easy to revisit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-07 13:42:46 +00:00
parent 1720f2274d
commit c13aff9f2b
+149 -104
View File
@@ -7,7 +7,7 @@
<meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time."> <meta name="description" content="Zero-dependency JSON mapping and validation for TypeScript classes. Maps JSON onto your own classes and checks the validation rules against the fields they are attached to, at compile time.">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<link rel="canonical" href="https://avalon-vanguard.github.io/cereale/"> <link rel="canonical" href="https://avalon-vanguard.github.io/cereale/">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌾</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'><rect x='4' width='2' height='18' fill='%23a0784a' opacity='.35'/><rect y='2' width='10' height='2' fill='%23a0784a'/><rect y='6' width='10' height='2' fill='%23a0784a'/><rect y='10' width='10' height='2' fill='%23a0784a'/><rect y='14' width='10' height='2' fill='%23a0784a'/></svg>">
<!-- Link previews. No og:image: a preview card with a broken image is worse than one <!-- Link previews. No og:image: a preview card with a broken image is worse than one
without, and there is no artwork to point at yet. --> without, and there is no artwork to point at yet. -->
@@ -22,35 +22,54 @@
<style> <style>
/* ---------------------------------------------------------------- tokens */ /* ---------------------------------------------------------------- tokens */
/* Chaff: one paper, one ink, one repeated 2px mark. The accent is rye-brown and
deliberately NOT wheat-gold — gold is both the cliche and a collision with --warn,
which already owns amber. Two amber families would make "our brand" and "needs
your attention" the same colour.
Every token below that differs between themes must be written in ALL FOUR places:
this block, the prefers-color-scheme block, [data-theme="light"] and
[data-theme="dark"]. Miss one and the toggle silently serves the light value. */
:root { :root {
--bg: #fbfbfd; --bg: #faf7f0;
--bg-raised: #ffffff; --bg-raised: #fffdf7;
--bg-sunken: #f3f3f7; --bg-sunken: #f2ede1;
--text: #16161d; --text: #1a1712;
--text-muted: #55555f; --text-muted: #5c5346;
--text-faint: #6b6b78; --text-faint: #6e6353;
--border: #e3e3ea; --border: #e6dfd1;
--border-strong: #cfcfd9; --border-strong: #cfc5b2;
--accent: #4f46e5; /* Icon-only controls need 3:1 against the page (WCAG 1.4.11); --border-strong is
--accent-text: #4338ca; decorative and sits well below it. .icon-btn and .btn-secondary use this instead. */
--accent-soft: #eef2ff; --border-ui: #8e8269;
--accent: #8a6238;
--accent-text: #7a5530;
--accent-soft: #f1e9dc;
/* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent /* Filled-accent surfaces carry their own foreground: the dark theme lightens --accent
for legibility against the page, which then leaves white text on it below AA. */ for legibility against the page, which then leaves white text on it below AA. */
--accent-solid: #4f46e5; --accent-solid: #6b4a28;
--on-accent: #ffffff; --on-accent: #fffdf7;
--bad: #d4183d; --bad: #a82820;
--bad-soft: #fff1f3; --bad-soft: #faebe7;
--ok: #08795a; --ok: #256b3d;
--ok-soft: #eefaf5; --ok-soft: #e8f2e9;
--warn: #9a5b00; --warn: #8a5a05;
--warn-soft: #fff7ea; --warn-soft: #fbf1dc;
/* Code surfaces stay dark in both themes: one syntax palette, always legible. */ /* Code surfaces stay dark in both themes: one syntax palette, always legible.
--code-bg: #16161f; Warmed off the blue-grey axis so the slab does not read cold against oat paper. */
--code-bg-raised: #1e1e29; --code-bg: #14120c;
--code-border: #2b2b3a; --code-bg-raised: #1c190f;
--code-text: #d6deeb; --code-border: #2e2818;
--code-faint: #8a93b8; --code-text: #e2dccb;
--code-faint: #9a9280;
/* The accent is unreadable on the dark slab in light mode, so focus rings drawn on a
code surface get their own token. Without it #editor:focus measures 2.86:1 — a live
1.4.11 failure on the shipped page. This measures 6.96:1. */
--accent-on-code: #c9962f;
/* The error line is the page's one visual device; these were three loose literals. */
--err-line: #ff6a7e;
--err-text: #ffb3c0;
--t-comment: #8a93b8; --t-comment: #8a93b8;
--t-string: #b8e08a; --t-string: #b8e08a;
--t-keyword: #c792ea; --t-keyword: #c792ea;
@@ -58,64 +77,70 @@
--t-type: #ffcb6b; --t-type: #ffcb6b;
--t-number: #f78c6c; --t-number: #f78c6c;
/* The whole ornament: a 2px mark every 9px. var() resolves against the winning
cascaded value, so --accent changing with the theme retints these automatically —
they belong on this block only, never in the three below. */
--grain-mark: 2px;
--grain-pitch: 9px;
--rule-x: repeating-linear-gradient(90deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--rule-y: repeating-linear-gradient(180deg, var(--accent) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--rule-code: repeating-linear-gradient(90deg, var(--code-faint) 0 var(--grain-mark), transparent var(--grain-mark) var(--grain-pitch));
--radius: 10px; --radius: 10px;
--radius-lg: 16px; --radius-lg: 16px;
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18); --shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--measure: 68ch; --measure: 64ch;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #0e0e14; --bg: #12100b;
--bg-raised: #16161f; --bg-raised: #1a1710;
--bg-sunken: #12121a; --bg-sunken: #16130d;
--text: #e8e8f0; --text: #ede7da;
--text-muted: #a3a3b4; --text-muted: #aba292;
--text-faint: #9494ab; --text-faint: #9b9280;
--border: #262633; --border: #29241a;
--border-strong: #363648; --border-strong: #3b3427;
--accent: #8b85ff; --border-ui: #736a59;
--accent-text: #a5a0ff; --accent: #c4a97c;
--accent-soft: #1c1b35; --accent-text: #d8be94;
--accent-solid: #8b85ff; --accent-soft: #2a2115;
--on-accent: #10101a; --accent-solid: #d8be94;
--bad: #ff8095; --on-accent: #17120a;
--bad-soft: #2a1620; --bad: #f2867e;
--ok: #5cd0a8; --bad-soft: #2b1512;
--ok-soft: #10241f; --ok: #6fc98c;
--warn: #e5a54b; --ok-soft: #12241a;
--warn-soft: #251d10; --warn: #e9b45a;
--code-bg: #12121a; --warn-soft: #261d0c;
--code-bg-raised: #191922; --shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
} }
} }
/* The toggle wins over the media query in both directions. */ /* The toggle wins over the media query in both directions. */
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #fbfbfd; --bg-raised: #ffffff; --bg-sunken: #f3f3f7; --bg: #faf7f0; --bg-raised: #fffdf7; --bg-sunken: #f2ede1;
--text: #16161d; --text-muted: #55555f; --text-faint: #6b6b78; --text: #1a1712; --text-muted: #5c5346; --text-faint: #6e6353;
--border: #e3e3ea; --border-strong: #cfcfd9; --border: #e6dfd1; --border-strong: #cfc5b2; --border-ui: #8e8269;
--accent: #4f46e5; --accent-text: #4338ca; --accent-soft: #eef2ff; --accent: #8a6238; --accent-text: #7a5530; --accent-soft: #f1e9dc;
--accent-solid: #4f46e5; --on-accent: #ffffff; --accent-solid: #6b4a28; --on-accent: #fffdf7;
--bad: #d4183d; --bad-soft: #fff1f3; --ok: #08795a; --ok-soft: #eefaf5; --bad: #a82820; --bad-soft: #faebe7; --ok: #256b3d; --ok-soft: #e8f2e9;
--warn: #9a5b00; --warn-soft: #fff7ea; --warn: #8a5a05; --warn-soft: #fbf1dc;
--code-bg: #16161f; --code-bg-raised: #1e1e29; --shadow: 0 1px 2px rgba(40, 30, 14, .05), 0 8px 24px -12px rgba(40, 30, 14, .20);
--shadow: 0 1px 2px rgba(16, 16, 30, .05), 0 8px 24px -12px rgba(16, 16, 30, .18);
color-scheme: light; color-scheme: light;
} }
:root[data-theme="dark"] { :root[data-theme="dark"] {
--bg: #0e0e14; --bg-raised: #16161f; --bg-sunken: #12121a; --bg: #12100b; --bg-raised: #1a1710; --bg-sunken: #16130d;
--text: #e8e8f0; --text-muted: #a3a3b4; --text-faint: #9494ab; --text: #ede7da; --text-muted: #aba292; --text-faint: #9b9280;
--border: #262633; --border-strong: #363648; --border: #29241a; --border-strong: #3b3427; --border-ui: #736a59;
--accent: #8b85ff; --accent-text: #a5a0ff; --accent-soft: #1c1b35; --accent: #c4a97c; --accent-text: #d8be94; --accent-soft: #2a2115;
--accent-solid: #8b85ff; --on-accent: #10101a; --accent-solid: #d8be94; --on-accent: #17120a;
--bad: #ff8095; --bad-soft: #2a1620; --ok: #5cd0a8; --ok-soft: #10241f; --bad: #f2867e; --bad-soft: #2b1512; --ok: #6fc98c; --ok-soft: #12241a;
--warn: #e5a54b; --warn-soft: #251d10; --warn: #e9b45a; --warn-soft: #261d0c;
--code-bg: #12121a; --code-bg-raised: #191922; --shadow: 0 1px 2px rgba(0, 0, 0, .45), 0 8px 24px -12px rgba(0, 0, 0, .7);
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -12px rgba(0, 0, 0, .7);
color-scheme: dark; color-scheme: dark;
} }
@@ -132,17 +157,23 @@ body {
color: var(--text); color: var(--text);
font-family: var(--sans); font-family: var(--sans);
font-size: 16px; font-size: 16px;
line-height: 1.6; line-height: 1.65;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
overflow-x: hidden; overflow-x: hidden;
} }
h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 680; letter-spacing: -.02em; } /* 680 and 640 are fiction on a non-variable system stack — they round to 700. And -.035em
h1 { font-size: clamp(2.1rem, 1.3rem + 3.4vw, 3.5rem); letter-spacing: -.035em; } is generic-landing-page tracking; it is most of what made this look like every other
h2 { font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.1rem); letter-spacing: -.028em; } dev-tool site. */
h3 { font-size: 1.125rem; } h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -.012em; }
h1 { font-size: clamp(2rem, 1.35rem + 2.8vw, 3rem); line-height: 1.14; letter-spacing: -.018em; }
h2 { font-size: clamp(1.4rem, 1.1rem + 1.3vw, 1.85rem); letter-spacing: -.012em; }
h3 { font-size: 1.0625rem; letter-spacing: -.008em; }
p { margin: 0 0 1rem; } p { margin: 0 0 1rem; }
a { color: var(--accent-text); text-decoration-color: color-mix(in srgb, var(--accent) 35%, transparent); text-underline-offset: .18em; } /* Links keep body colour and are marked by a rule instead. On a palette this warm,
a:hover { text-decoration-color: currentColor; } --accent-text and --text-muted sit 1.14:1 apart — colour alone would lose them. */
a { color: var(--text); text-decoration-color: var(--accent); text-decoration-thickness: 2px; text-underline-offset: .16em; }
a:hover { color: var(--accent-text); text-decoration-color: currentColor; }
.nav-links a, .foot-grid a, .brand { text-decoration: none; }
code, kbd, pre { font-family: var(--mono); } code, kbd, pre { font-family: var(--mono); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
@@ -153,10 +184,17 @@ code, kbd, pre { font-family: var(--mono); }
section { padding-block: clamp(3rem, 6vw, 5.5rem); } section { padding-block: clamp(3rem, 6vw, 5.5rem); }
.lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); } .lede { color: var(--text-muted); font-size: 1.0625rem; max-width: var(--measure); }
.eyebrow { .eyebrow {
font-size: .75rem; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--mono);
color: var(--accent-text); margin: 0 0 .6rem; font-size: .6875rem; font-weight: 500; letter-spacing: .14em; text-transform: uppercase;
color: var(--accent-text); margin: 0 0 .7rem;
} }
.section-head { margin-bottom: 2.25rem; } .section-head { margin-bottom: 2.25rem; }
/* The ornament, use 1 of 3: a section begins. The hero is the page beginning, not a
section, so it deliberately has no .section-head and no mark. */
.section-head::before {
content: ""; display: block; width: 4.5rem; height: 3px;
margin-bottom: .95rem; background-image: var(--rule-x);
}
.skip { .skip {
position: absolute; left: -9999px; top: 0; z-index: 100; position: absolute; left: -9999px; top: 0; z-index: 100;
background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0; background: var(--accent-solid); color: var(--on-accent); padding: .6rem 1rem; border-radius: 0 0 var(--radius) 0;
@@ -175,8 +213,8 @@ header.nav {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; } .nav-inner { display: flex; align-items: center; gap: 1rem; height: 3.75rem; }
.brand { display: flex; align-items: baseline; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; } .brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: -.02em; font-size: 1.125rem; color: var(--text); text-decoration: none; }
.brand .grain { font-size: 1rem; } .brand .mark { color: var(--accent); flex: none; }
.badge { .badge {
font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px; font: 600 .6875rem/1 var(--mono); padding: .3rem .45rem; border-radius: 999px;
background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); background: var(--accent-soft); color: var(--accent-text); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
@@ -187,10 +225,13 @@ header.nav {
} }
.nav-links a:hover { color: var(--text); background: var(--bg-sunken); } .nav-links a:hover { color: var(--text); background: var(--bg-sunken); }
.nav-links a.ghost { border: 1px solid var(--border-strong); } .nav-links a.ghost { border: 1px solid var(--border-strong); }
@media (max-width: 640px) { .nav-hide { display: none; } } /* The section links need ~810px before they stop pushing the header past the viewport.
At 640px they already did not fit: the page overflowed by 63px through the whole
641-767px band, which body{overflow-x:hidden} hid rather than fixed. */
@media (max-width: 860px) { .nav-hide { display: none; } }
.icon-btn { .icon-btn {
display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem; display: inline-grid; place-items: center; width: 2.125rem; height: 2.125rem;
border: 1px solid var(--border-strong); border-radius: var(--radius); border: 1px solid var(--border-ui); border-radius: var(--radius);
background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0; background: var(--bg-raised); color: var(--text-muted); cursor: pointer; padding: 0;
} }
.icon-btn:hover { color: var(--text); } .icon-btn:hover { color: var(--text); }
@@ -209,14 +250,14 @@ header.nav {
} }
.btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); } .btn-primary { background: var(--accent-solid); color: var(--on-accent); box-shadow: var(--shadow); }
.btn-primary:hover { filter: brightness(1.08); } .btn-primary:hover { filter: brightness(1.08); }
.btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-strong); } .btn-secondary { background: var(--bg-raised); color: var(--text); border-color: var(--border-ui); }
.btn-secondary:hover { border-color: var(--text-faint); } .btn-secondary:hover { border-color: var(--text-faint); }
.fact-row { .fact-row {
display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: .4rem .5rem; margin-top: 1.5rem;
font-size: .8125rem; color: var(--text-muted); font-size: .8125rem; color: var(--text-muted);
} }
.fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; } .fact { border: 1px solid var(--border); background: var(--bg-raised); border-radius: 999px; padding: .3rem .7rem; }
.fact b { color: var(--text); font-weight: 640; } .fact b { color: var(--text); font-weight: 600; }
/* ---------------------------------------------------------------- code */ /* ---------------------------------------------------------------- code */
.code { .code {
@@ -228,8 +269,11 @@ header.nav {
border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised); border-bottom: 1px solid var(--code-border); background: var(--code-bg-raised);
font: 500 .75rem/1 var(--mono); color: var(--code-faint); font: 500 .75rem/1 var(--mono); color: var(--code-faint);
} }
.code-head .dot { width: .5rem; height: .5rem; border-radius: 50%; background: #33334a; } /* Use 3 of 3, and a deletion: the fake traffic lights are gone. */
.code-head .name { margin-left: .35rem; } .code-head::before {
content: ""; flex: none; width: 1.05rem; height: 2px;
background-image: var(--rule-code);
}
.code-head .right { margin-left: auto; } .code-head .right { margin-left: auto; }
.code pre { .code pre {
margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto; margin: 0; padding: 1.1rem 1.15rem; overflow-x: auto;
@@ -246,18 +290,18 @@ header.nav {
/* The page's one visual device: the line the compiler refuses. */ /* The page's one visual device: the line the compiler refuses. */
.ln--error { .ln--error {
background: rgba(255, 92, 122, .09); background: rgba(255, 106, 126, .09);
text-decoration: underline wavy #ff5c7a; text-decoration: underline wavy var(--err-line);
text-decoration-skip-ink: none; text-decoration-skip-ink: none;
text-underline-offset: .32em; text-underline-offset: .32em;
} }
.tsc-error { .tsc-error {
display: flex; gap: .6rem; align-items: flex-start; display: flex; gap: .6rem; align-items: flex-start;
margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border); margin: 0; padding: .7rem .9rem; border-top: 1px solid var(--code-border);
background: rgba(255, 92, 122, .08); color: #ffb3c0; background: rgba(255, 106, 126, .08); color: var(--err-text);
font: 500 .78125rem/1.5 var(--mono); font: 500 .78125rem/1.5 var(--mono);
} }
.tsc-error .mark { color: #ff5c7a; flex-shrink: 0; } .tsc-error .mark { color: var(--err-line); flex-shrink: 0; }
/* --------------------------------------------------------------- panels */ /* --------------------------------------------------------------- panels */
.panel { .panel {
@@ -303,7 +347,7 @@ header.nav {
padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre; padding: 1.1rem 1.15rem; font: .84375rem/1.75 var(--mono); tab-size: 2; white-space: pre;
overflow: auto; overflow: auto;
} }
#editor:focus { outline: 2px solid var(--accent); outline-offset: -2px; } #editor:focus { outline: 2px solid var(--accent-on-code); outline-offset: -2px; }
#output { #output {
flex: 1; min-height: 400px; margin: 0; overflow: auto; flex: 1; min-height: 400px; margin: 0; overflow: auto;
background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border); background: var(--code-bg); color: var(--code-text); border: 1px solid var(--code-border);
@@ -344,7 +388,8 @@ td.note { white-space: normal; color: var(--text-muted); font-size: .875rem; }
/* --------------------------------------------------------------- prose */ /* --------------------------------------------------------------- prose */
.notes { display: grid; gap: .9rem; } .notes { display: grid; gap: .9rem; }
.note-item { border-left: 2px solid var(--border-strong); padding-left: 1rem; } /* Use 2 of 3: the same rhythm stood on end. */
.note-item { padding-left: 1rem; background: var(--rule-y) left top / 2px 100% no-repeat; }
.note-item h3 { font-size: .9375rem; margin-bottom: .25rem; } .note-item h3 { font-size: .9375rem; margin-bottom: .25rem; }
.note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; } .note-item p { color: var(--text-muted); font-size: .9375rem; margin: 0; }
.callout { .callout {
@@ -365,7 +410,7 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
<header class="nav"> <header class="nav">
<div class="wrap nav-inner"> <div class="wrap nav-inner">
<a class="brand" href="#top"><span class="grain" aria-hidden="true">🌾</span>cereale <span class="badge" id="version-badge">v0.4.0</span></a> <a class="brand" href="#top"><svg class="mark" width="10" height="18" viewBox="0 0 10 18" aria-hidden="true" focusable="false"><rect x="4" y="0" width="2" height="18" fill="currentColor" opacity=".3"/><rect x="0" y="2" width="10" height="2" fill="currentColor"/><rect x="0" y="6" width="10" height="2" fill="currentColor"/><rect x="0" y="10" width="10" height="2" fill="currentColor"/><rect x="0" y="14" width="10" height="2" fill="currentColor"/></svg>cereale <span class="badge" id="version-badge">v0.4.0</span></a>
<nav class="nav-links" aria-label="Primary"> <nav class="nav-links" aria-label="Primary">
<a class="nav-hide" href="#guarantee">Guarantee</a> <a class="nav-hide" href="#guarantee">Guarantee</a>
<a class="nav-hide" href="#playground">Playground</a> <a class="nav-hide" href="#playground">Playground</a>
@@ -415,7 +460,7 @@ footer { border-top: 1px solid var(--border); padding-block: 2.5rem; color: var(
<div> <div>
<div class="code"> <div class="code">
<div class="code-head"> <div class="code-head">
<span class="dot" aria-hidden="true"></span><span class="name">order.ts</span> <span class="name">order.ts</span>
</div> </div>
<pre><code data-lang="ts" data-error-line="9">class Order { <pre><code data-lang="ts" data-error-line="9">class Order {
@JsonProperty('order_ref') @JsonProperty('order_ref')
@@ -466,7 +511,7 @@ order.total(); // methods intact</code></pre>
<div> <div>
<p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p> <p class="compare-label"><span class="pill pill-bad">compiles, then fails at runtime</span> class-validator</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with legacy decorators</span></div> <div class="code-head"><span class="name">with legacy decorators</span></div>
<pre><code data-lang="ts">class User { <pre><code data-lang="ts">class User {
@IsString() @IsString()
age: number; // accepted by the compiler age: number; // accepted by the compiler
@@ -479,7 +524,7 @@ order.total(); // methods intact</code></pre>
<div> <div>
<p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p> <p class="compare-label"><span class="pill pill-ok">rejected before it runs</span> cereale</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">with standard decorators</span></div> <div class="code-head"><span class="name">with standard decorators</span></div>
<pre><code data-lang="ts" data-error-line="2">class User { <pre><code data-lang="ts" data-error-line="2">class User {
@IsString() @IsString()
age!: number; // Type 'number' is not age!: number; // Type 'number' is not
@@ -523,7 +568,7 @@ order.total(); // methods intact</code></pre>
</div> </div>
<div class="compare"> <div class="compare">
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">catalogue.ts</span></div> <div class="code-head"><span class="name">catalogue.ts</span></div>
<pre><code data-lang="ts">class Media { <pre><code data-lang="ts">class Media {
@IsString() title!: string; @IsString() title!: string;
} }
@@ -545,7 +590,7 @@ class Playlist {
}</code></pre> }</code></pre>
</div> </div>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what comes out</span></div> <div class="code-head"><span class="name">what comes out</span></div>
<pre><code data-lang="ts">const list = fromJsonSync(Playlist, body); <pre><code data-lang="ts">const list = fromJsonSync(Playlist, body);
const first = list.items[0]; const first = list.items[0];
@@ -590,14 +635,14 @@ if (first instanceof Movie) {
<div class="pg-grid"> <div class="pg-grid">
<div class="pg-pane"> <div class="pg-pane">
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0"> <div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
<span class="dot" aria-hidden="true"></span><span class="name">playground.ts</span> <span class="name">playground.ts</span>
</div> </div>
<textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" <textarea id="editor" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
aria-label="TypeScript source to run"></textarea> aria-label="TypeScript source to run"></textarea>
</div> </div>
<div class="pg-pane"> <div class="pg-pane">
<div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0"> <div class="code-head" style="border:1px solid var(--code-border);border-bottom:none;border-radius:var(--radius-lg) var(--radius-lg) 0 0">
<span class="dot" aria-hidden="true"></span><span class="name">output</span> <span class="name">output</span>
<span class="right" id="pg-status"></span> <span class="right" id="pg-status"></span>
</div> </div>
<pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre> <pre id="output" aria-live="polite" aria-atomic="false" role="status"></pre>
@@ -641,7 +686,7 @@ if (first instanceof Movie) {
</div> </div>
<div style="margin-top:1.5rem" class="code"> <div style="margin-top:1.5rem" class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">what you get instead</span></div> <div class="code-head"><span class="name">what you get instead</span></div>
<pre><code data-lang="text">JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON. <pre><code data-lang="text">JsonMappingError: lines[1].tags[0] is a Set, which cannot be serialized to JSON.
Give the property a @JsonSerialize() serializer that converts it, or drop it from Give the property a @JsonSerialize() serializer that converts it, or drop it from
the output with @JsonIgnore(). the output with @JsonIgnore().
@@ -719,7 +764,7 @@ Break the cycle with @JsonIgnore() on the back-reference, or supply a
</p> </p>
<div class="code" style="margin-top:1.25rem;max-width:640px"> <div class="code" style="margin-top:1.25rem;max-width:640px">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">vite.config.ts</span></div> <div class="code-head"><span class="name">vite.config.ts</span></div>
<pre><code data-lang="ts">import { defineConfig } from 'vite'; <pre><code data-lang="ts">import { defineConfig } from 'vite';
import { standardDecorators } from 'cereale/vite'; import { standardDecorators } from 'cereale/vite';
@@ -751,7 +796,7 @@ export default defineConfig({
<div> <div>
<p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p> <p class="compare-label"><span class="pill pill-ok">tsconfig.json</span> what the compiler needs</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">tsconfig.json</span></div> <div class="code-head"><span class="name">tsconfig.json</span></div>
<pre><code data-lang="text">{ <pre><code data-lang="text">{
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
@@ -771,7 +816,7 @@ export default defineConfig({
<div> <div>
<p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p> <p class="compare-label"><span class="pill pill-bad">not on npm yet</span> installing it today</p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">shell</span></div> <div class="code-head"><span class="name">shell</span></div>
<pre><code data-lang="text">git clone https://github.com/avalon-vanguard/cereale <pre><code data-lang="text">git clone https://github.com/avalon-vanguard/cereale
cd cereale cd cereale
npm install &amp;&amp; npm run build npm install &amp;&amp; npm run build
@@ -797,7 +842,7 @@ npm install ../cereale/cereale-0.4.0.tgz</code></pre>
a bare <code class="inline-code">&lt;script type="module"&gt;</code>, Deno and Workers. a bare <code class="inline-code">&lt;script type="module"&gt;</code>, Deno and Workers.
</p> </p>
<div class="code"> <div class="code">
<div class="code-head"><span class="dot" aria-hidden="true"></span><span class="name">index.html</span></div> <div class="code-head"><span class="name">index.html</span></div>
<pre><code data-lang="text">&lt;script type="importmap"&gt; <pre><code data-lang="text">&lt;script type="importmap"&gt;
{ "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } } { "imports": { "cereale": "./node_modules/cereale/dist/cereale.min.js" } }
&lt;/script&gt; &lt;/script&gt;