Skip to main content

moss_core/contract/
custom_props.rs

1//! Theming escape hatches — Source 2b of the federated contract.
2//!
3//! Two tables that do not fit the class-keyed [`super::components::COMPONENTS`]
4//! table, kept here rather than bolted onto it.
5//!
6//! Why they are separate: 167 component entries would each need an empty array
7//! that says nothing, and several of these hooks have no single owning class at
8//! all — `--moss-nav-width` resolves against the `<body>`-level content width,
9//! `--moss-escape` is set by `[data-width]` on any block, and `data-page` lives
10//! on `<body>`, which carries no `moss-*` class. Inventing a `moss-body` entry
11//! to give it a home would have added an orphan contract entry for a class moss
12//! never emits — precisely what teaches agents to target dead selectors (#777).
13//!
14//! ## Adding a hook
15//!
16//! 1. Read it from a stylesheet as `var(--moss-foo, <fallback>)`.
17//! 2. Add a [`CustomProp`] here, with `default` copied **verbatim** from the
18//!    call site. A declared hook with an invented default is worse than an
19//!    undeclared one: an agent reasons from the wrong starting point and has no
20//!    way to tell.
21//! 3. `cargo test --test components_sync_test` — `every_escape_hatch_is_declared`
22//!    fails on a read nothing declares, and `every_declared_custom_prop_is_read`
23//!    fails on a declaration nothing reads.
24
25/// A CSS custom property a theme may set to reconfigure a component.
26///
27/// These are read by moss's stylesheets as `var(--moss-foo, <fallback>)` and
28/// are deliberately **never declared** — that is exactly what makes them opt-in
29/// escape hatches rather than design tokens. A token has a value in `:root` and
30/// cascades site-wide; one of these has no value until a theme sets it, and it
31/// is set *on a component or a scope* to change that component.
32///
33/// The distinction matters because it is the whole theming API in practice.
34/// Audited 2026-08-03: the two most heavily customized moss sites (okagaki, 在場)
35/// overrode **zero** design tokens between them and set six of these. None was
36/// discoverable — not in `moss describe --json`, not in any published doc — so
37/// okagaki hand-fought the hero height caps that `--moss-hero-max-height` exists
38/// to lift, across three selectors and a 12-line comment.
39pub struct CustomProp {
40    /// Property name including the leading dashes (e.g. `"--moss-hero-max-height"`).
41    pub name: &'static str,
42    /// Class of the component whose rules read it, or a scope selector when no
43    /// single class owns it (e.g. `"body"`). Free-form: this is documentation,
44    /// not a foreign key.
45    pub owner: &'static str,
46    /// The fallback moss's own CSS uses when the theme does not set it.
47    /// Taken verbatim from the `var()` call site — never invented.
48    pub default: &'static str,
49    /// What setting it does, and when you would want to.
50    pub description: &'static str,
51}
52
53/// Every escape-hatch custom property moss's stylesheets read.
54///
55/// Kept as its own table rather than a field on [`ComponentEntry`] for two
56/// reasons: 167 entries would each need a `custom_props: &[]` that says
57/// nothing, and several of these are not owned by a single class anyway
58/// (`--moss-nav-width` is read against the `<body>`-level content width;
59/// `--moss-escape` is set by `[data-width]` on any block).
60///
61/// Enforced by `every_escape_hatch_is_declared` in
62/// `src-tauri/tests/components_sync_test.rs`: a `var(--moss-*, …)` read that no
63/// entry here declares fails the build. That test is the point of the table —
64/// a hook nothing declares is a hook no agent can find.
65pub const CUSTOM_PROPS: &[CustomProp] = &[
66    CustomProp {
67        name: "--moss-hero-max-height",
68        owner: "moss-hero",
69        default: "70vh",
70        description: "Cap on hero media height on desktop. Set `none` for a hero that fills its container. Note the wrapper has its own cap — `.moss-hero { max-height: min(80vh, 800px) }` reads the same property, so setting it once lifts both.",
71    },
72    CustomProp {
73        name: "--moss-hero-object-position",
74        owner: "moss-hero",
75        default: "top",
76        description: "Crop anchor for hero media, which is `object-fit: cover`. The default anchors the top, which suits landscapes; use `center` for portraits and faces.",
77    },
78    CustomProp {
79        name: "--moss-nav-island-display",
80        owner: "moss-nav-island",
81        default: "block",
82        description: "Set to `none` to turn the floating nav island off site-wide — the page then behaves as it did before ADR-049: the masthead scrolls away and nothing replaces it. This is the island's whole tuning surface on purpose; its measure already tracks `--moss-nav-width`/`--moss-content-width`, so widening the nav widens the island with it.",
83    },
84    CustomProp {
85        name: "--moss-hint-x",
86        owner: "[data-tooltip]",
87        default: "0px",
88        description: "Horizontal offset of a hover hint's pill from its host's start edge. Written per-element at runtime by theme.js (hint-place.ts), which measures the pill on hover/focus entry and clamps it into the viewport so a hint can never crop at a screen edge. Not a theme hook: a hand-set value is overwritten on the next hover.",
89    },
90    CustomProp {
91        name: "--moss-hint-max-w",
92        owner: "[data-tooltip]",
93        default: "calc(100vw - 24px)",
94        description: "Widest a hover hint's pill may get before it wraps. Written per-element at runtime by theme.js (hint-place.ts) alongside `--moss-hint-x`, because the CSS fallback's `100vw` counts the scrollbar gutter as usable space and no CSS length can subtract it. Not a theme hook: a hand-set value is overwritten on the next hover.",
95    },
96    CustomProp {
97        name: "--moss-grid-ratio",
98        owner: "moss-grid",
99        default: "repeat(N, minmax(0, 1fr))",
100        description: "Track widths for a `:::grid`, as a `grid-template-columns` value. moss sets it on the element when the author writes a ratio (`:::grid 2 1:2` → `2fr 1fr`); the fallback is the even split for whatever `data-columns` says, and a ratio-less grid with no `data-columns` falls back to `initial`. It is a property rather than an inline `grid-template-columns` on purpose: an inline declaration beats every stylesheet rule, including the mobile collapse, so a ratio grid stayed multi-column on a phone. A theme setting this by hand overrides the author's ratio at every width — the mobile collapse still wins, because that rule does not read the property.",
101    },
102    CustomProp {
103        name: "--moss-grid-image-ratio",
104        owner: "moss-grid-card",
105        default: "1 / 1",
106        description: "Aspect ratio of images inside a `:::grid`. Set to the source art's own ratio when the image is a designed artifact whose edges carry meaning (a poster, a titled tile) rather than a photograph.",
107    },
108    CustomProp {
109        name: "--moss-grid-image-radius",
110        owner: "moss-grid-card",
111        default: "8px",
112        description: "Corner radius of grid images. `50%` makes circular portraits; `0` suits art that has its own designed corners.",
113    },
114    CustomProp {
115        name: "--moss-grid-image-fit",
116        owner: "moss-grid-card",
117        default: "cover",
118        description: "`object-fit` for grid images. `contain` letterboxes onto the surface colour instead of cropping — the right choice for typographic work, where a crop costs words rather than scenery.",
119    },
120    CustomProp {
121        name: "--moss-card-cover-ratio",
122        owner: "moss-card-cover",
123        default: "4 / 3",
124        description: "Aspect ratio of card cover images. Same reasoning as `--moss-grid-image-ratio`, for `:::cards` rather than `:::grid`.",
125    },
126    CustomProp {
127        name: "--moss-card-cover-fit",
128        owner: "moss-card-cover",
129        default: "cover",
130        description: "`object-fit` for card covers. `contain` for artwork whose edges carry meaning; `cover` stays right for photography.",
131    },
132    CustomProp {
133        name: "--moss-card-min",
134        owner: "moss-cards",
135        default: "280px",
136        description: "Minimum column width in the auto-filled card grid. Lower it for denser grids of short items, raise it to force fewer, wider cards.",
137    },
138    CustomProp {
139        name: "--moss-cover-color",
140        owner: "moss-card",
141        default: "var(--moss-bg, var(--moss-color-bg, #fff))",
142        description: "Background behind card content when the card carries `data-cover-color`. moss sets this per-card from the cover image's dominant colour; a theme can override it to opt out of the extracted tint.",
143    },
144    CustomProp {
145        name: "--moss-bg",
146        owner: "moss-card",
147        default: "var(--moss-color-bg, #fff)",
148        description: "Fallback background in the `--moss-cover-color` chain, for a scope that wants a different neutral than the site background without redefining the `--moss-color-bg` token.",
149    },
150    CustomProp {
151        name: "--moss-nav-width",
152        owner: "main-nav",
153        default: "var(--moss-content-width)",
154        description: "Width of the header nav's inner row. Unset (the default) the nav tracks the `<body>`-level content width, so it stays aligned with the article column through every `content_width` preset. Set it only to deliberately break that alignment.",
155    },
156    CustomProp {
157        name: "--moss-escape",
158        owner: "[data-width]",
159        default: "100%",
160        description: "Width a `data-width` block escapes to. moss sets it per keyword (`wide`, `page`, `screen`); set it directly for a width the keywords do not cover. Always clamped by `min(…, 100cqw)`, so a narrow viewport stays safe.",
161    },
162    CustomProp {
163        name: "--moss-success",
164        owner: "moss-input-feedback",
165        default: "#10b981",
166        description: "Colour of a success message under a form field. Deliberately not a token: it is one accent moss does not want to spend a site-wide variable on.",
167    },
168    CustomProp {
169        name: "--moss-error",
170        owner: "moss-input-feedback",
171        default: "#c85450",
172        description: "Colour of an error message under a form field, and of comment-thread error states.",
173    },
174    CustomProp {
175        name: "--moss-radius-md",
176        owner: "moss-subscribe",
177        default: "0.5rem",
178        description: "Corner radius of the subscribe card. Set to `0` for a square-cornered form that matches a flat theme.",
179    },
180];
181
182/// A `data-*` attribute moss emits on an element that carries no `moss-*` class.
183///
184/// `<body data-page="home">` is the case that forced this to exist. It is a
185/// first-class part of the contract — the only thing that tells a stylesheet
186/// which page it is on, and okagaki's entire front page hangs off it — but
187/// `<body>` has no class, so declaring it would have meant inventing a
188/// `moss-body` entry for a class moss never emits. That is the orphan-entry
189/// problem (#777) in miniature: a contract that names selectors moss does not
190/// produce teaches agents to target dead ones.
191pub struct ScopeAttr {
192    /// CSS selector for the element carrying it (e.g. `"body"`).
193    pub selector: &'static str,
194    /// Attribute name including the `data-` prefix.
195    pub name: &'static str,
196    /// Allowed values. Empty means free-form.
197    pub values: &'static [&'static str],
198    /// What it marks, and what to scope to it.
199    pub description: &'static str,
200}
201
202/// Structural attributes on classless elements. See [`ScopeAttr`].
203pub const SCOPE_ATTRS: &[ScopeAttr] = &[
204    ScopeAttr {
205        selector: "body",
206        name: "data-page",
207        values: &["home"],
208        description: "Present as `home` on the site's front page only. Scope front-page-only rules to `body[data-page=\"home\"]` rather than to something merely unique to your homepage today — a hero that fills the screen, a suppressed footer, a different nav treatment.",
209    },
210    ScopeAttr {
211        selector: "html",
212        name: "data-theme",
213        values: &["light", "dark"],
214        description: "The resolved colour scheme, set on `<html>` before the first paint by an inline script, from the reader's stored choice or their OS preference. So `[data-theme=\"dark\"]` alone is sufficient for dark-mode rules — do not also write an `@media (prefers-color-scheme: dark)` block, which ignores the toggle and will disagree with it.",
215    },
216    ScopeAttr {
217        selector: "article > [data-width]",
218        name: "data-width",
219        values: &["body", "wide", "page", "screen"],
220        description: "Set by block shortcodes to escape the text column. The width resolves through `--moss-escape`, clamped by `min(…, 100cqw)` so a narrow viewport stays safe.",
221    },
222    ScopeAttr {
223        selector: "body",
224        name: "data-typesetting",
225        values: &["vertical"],
226        description: "Present as `vertical` when the page is set in vertical CJK writing mode, from `[site] typesetting` in `.moss/config.toml` or a page's `typesetting:` frontmatter. Absent means horizontal. It reorients roughly 50 rules — nav, article flow, scroll direction — so a theme for a vertical site scopes to `body[data-typesetting=\"vertical\"]` rather than reinventing the mode.",
227    },
228    ScopeAttr {
229        selector: "body",
230        name: "data-content-width",
231        values: &["wide", "full"],
232        description: "Present when a page widens its column via `content_width:` frontmatter; absent at the default reading measure. This is what `--moss-content-width` — and therefore `--moss-nav-width`, which tracks it — resolves against, so it is the hook for a layout that should respond to the preset rather than to a fixed width.",
233    },
234];