Skip to content
Merged
18 changes: 10 additions & 8 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ Prose uses exactly three tiers — `--foreground`, `--foreground-secondary`, `--
- **The Three-Tier Reading Rule.** Prose uses primary, secondary, or muted. Neutral washes are surfaces, not extra text tiers. `--foreground-dimmed` is retired — its call sites name secondary directly — and must never come back with a definition of its own (contract-tested).
- **The One Colorspace Rule.** Every derivation inside a token family uses one colorspace (`oklch` for ink, contract-tested). Mixing `srgb` and `oklch` derivations produces "same literal, different value" drift, and the ladder is where it bites hardest because its rungs only mean anything relative to each other.
- **Astryx's `secondary` is not this ladder's secondary.** `Text color="secondary"` reads `--color-text-secondary`, which resolves to a fixed neutral literal outside the containers `astryx-mount.css` bridges and to `--muted-foreground` inside them. Neither is `--foreground-secondary`. One word, three results — so pick an ink tier by the token, and treat a primitive's color prop as its own vocabulary.
- **`--color-text-disabled` is a deliberate exemption, not an oversight.** Astryx's `light-dark(#a3a3a3, #525252)` measures 2.52:1 / 2.29:1 — under the AA floor the three prose tiers hold. It stays: a disabled control read at prose contrast stops reading as disabled, and the versions of this token that clear 4.5:1 looked wrong beside the enabled rows they sit in. It is not prose and it is not a fourth tier; it is the one place the floor is traded for the state being legible *as a state*. Do not "fix" it, and do not cite it as precedent for another sub-AA value — an audit that finds it has found the exemption, not a bug.
- **Links use the solid accent tier** (`--accent-solid`), never raw `--accent` — the accent identifies interaction, and the solid tier is the only accent variant that clears text contrast on every palette. A selection wash or outline is not link text and derives from `--accent` instead — deriving a surface from the link name lets a text-contrast rule silently govern a background.

## 4. Borders
Expand Down Expand Up @@ -144,12 +145,12 @@ Nothing interactive is square. One ladder, assigned monotonically by box height:
| Radius | Maka tier | Astryx tier | Assign to |
|---|---|---|---|
| 6px | control | inner | chips, keycaps, nested inlays, and product-drawn compact controls |
| 10px | card | element | cards, rows-as-cards, list containers; Astryx `Button`, `Input`, `SegmentedControl` |
| 12px | container | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` |
| 10px | surface | element | cards, rows-as-cards, list containers; Astryx `Button`, `Input`, `SegmentedControl` |
| 12px | modal | container | modals, panels, portal surfaces; Astryx `Card`, `Dialog`, `DropdownMenu` |
| 28px | chat | chat | the conversation surface as one shape: user bubble, assistant bubble, composer dock. Sourced from Astryx's `--radius-chat` by `ChatMessageBubble` and `ChatComposer` themselves; product CSS never restates it |
| full | pill (999px) | full (9999px) | badges, pills, circular controls |
| pill | pill | full | badges, pills, circular controls |

- **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `card` is Astryx's `element`, Maka's `modal` is Astryx's `container`. Resolve a tier from the box, never from the token name that sounds right. The paired values agree *today* but are independent literals, not aliases — an Astryx upgrade can move one side silently, so a mismatch is a real failure mode rather than an impossibility. The chat rung is the one tier the product does not assign: `ChatMessageBubble` and `ChatComposer` both resolve `--radius-chat` on their own, which is why the bubble and the dock round together, and why setting a bubble radius or a non-default `density` in product code silently breaks the pair. Astryx's `--radius-page` carries the same 28px literal but is a different token with no Maka tier and no product consumer; reaching for it to match the chat surface is inventing a rung.
- **The Two-Name Rule.** These are one ladder under two vocabularies, and the names never line up: Maka's `control` is Astryx's `inner`, Maka's `surface` is Astryx's `element`, Maka's `modal` is Astryx's `container`, Maka's `pill` is Astryx's `full`. Resolve a tier from the box, never from the token name that sounds right. **The px side is the authority and the Astryx name is an alias to it** — `makaTheme.ts` emits `--radius-element: var(--radius-surface)` and its three siblings, so an upstream rung change cannot move one name out from under the other, and the ladder cannot be half in px and half in rem (which the 16px-root note in that file is the reason to avoid). Both vocabularies are live in product CSS and both are correct to read; they resolve to the same number by construction rather than by coincidence. The chat rung is the one tier the product does not assign: `ChatMessageBubble` and `ChatComposer` both resolve `--radius-chat` on their own, which is why the bubble and the dock round together, and why setting a bubble radius or a non-default `density` in product code silently breaks the pair. Astryx's `--radius-page` carries the same 28px literal but is a different token with no Maka tier and no product consumer; reaching for it to match the chat surface is inventing a rung.
- **The Full-Bleed Rule.** `border-radius: 0` is legal only on true full-bleed rows — an element flush with its container on both sides. Radius and gap move together: if it has breathing room, it has corners.
- **Proportional marks.** Product-drawn icon plates use ratio-owned radius (~25–27% of the box edge), recorded in prose because Stitch accepts only absolute units.

Expand Down Expand Up @@ -177,8 +178,9 @@ The palette is cool-neutral and quiet; color is generated to spec, not picked by

- **Brand mark** is fixed `#71a8fd`; it identifies Maka and is never the general CTA color.
- **Interaction accent** follows the active palette for focus, selection, and live state; **links and accent-colored text use the solid tier** (§3). Astryx's own semantic components are the exception — `Badge` and `StatusDot` carry fixed literals inherited from the neutral theme and follow neither the palette nor the families below.
- **Status families** — `--info`, `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than blue — and flattening it would abandon the shared-lightness premise that makes them a family. All four are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, washes must not shout. These are colors; what a state *means* is a separate vocabulary (§9).
- **Tinted surfaces** (status washes behind rows and banners) derive from the same status hues; hand-rolled `oklch()` status washes at call sites are forbidden — consume the family. The family is `--{status}-wash` (0.08 fill) and `--{status}-wash-border` (0.24, ~3x the fill), every member derived with `oklch(from var(--{status}) ...)` so a status regeneration flows through it. A **strong** tier (0.12 / 0.40) exists for warnings about data destruction or an action the user cannot undo, and for nothing else — it is not the loud option for a notice that wants attention. Palette swatches are not washes: a swatch's job is to show a palette's real colour, so its literals stay. The family is kept complete even where a rung has no consumer yet: a family with holes in it sends the next author back to hand-rolling an alpha, which is the etiology of the fourteen that drifted.
- **Status families** — `--success`, `--warning`, `--destructive` — are generated, not picked: one lightness per mode, each hue keeping its own chroma, every member clearing AA. These are Astryx's three statuses, and Maka has no fourth: there is **no `--info` colour**. "Info" is a semantic slot, not a hue — a `tone="info"` tile paints with the accent, so an informational surface follows the user's palette instead of introducing a second blue that competes with it. `<Banner status="info">` is the one place that does not yet: `.astryx-banner.info` re-declares the tint and the text colour **on the banner element itself**, which no ancestor bridge can outrank, so info banners stay upstream's fixed blue. Converging that means overriding a component rule rather than a token, and it is tracked on #3446, not done here. The residual contrast spread within a mode is hue physics — at equal lightness, yellow carries more luminance than red — and flattening it would abandon the shared-lightness premise that makes them a family. All three are declared in one block per mode, because the time warning sat thirty lines from its siblings is the time it lost its dark override and dropped under AA unnoticed. A louder band at ~90% gamut chroma exists only for 8px status dots: dots must read at a glance, tinted surfaces must not shout. These are colors; what a state *means* is a separate vocabulary (§9).
- **Tinted surfaces** are Astryx's own tokens: `--color-success-muted`, `--color-warning-muted`, `--color-error-muted` and `--color-accent-muted`. Product CSS reads those four names and nothing else; a hand-rolled `oklch(from var(--destructive) …)` at a call site is forbidden. **One rung, no border rung, no strong tier** — the shape upstream ships. All four are `oklch(from var(--{hue}) l c h / 0.24)`, the alpha the neutral theme's own pastels already sit at, so a Banner and a product row painted side by side read as one material. Same weight is not enough: they must be the same **construction**, because an opaque tint does not compose, and a tinted chip on a tinted panel then resolves to its parent's exact pixel and vanishes. The three status rungs are emitted by `makaTheme.ts`; `--color-accent-muted` needs the unlayered bridge in `maka-tokens.css` instead, because `maka.css` re-declares the accent pair at component level and a root rule in the same layer would lose. A border around a tinted surface is `--border`: the fill already states the status, and a second tinted edge is §4's One Means Rule. A tinted border on an **un**tinted surface is a different case and does read a `-muted` token — the stat tile's outline tones and the tool-output edges are the live examples. The Maka `--{status}-wash` / `-wash-border` vocabulary this replaced was a second authority for the same surface — its two tiers and four rungs each grew a call site that disagreed with the rung beside it. Palette swatches are not tinted surfaces: a swatch's job is to show a palette's real colour, so its literals stay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: this paragraph says --color-accent-muted is one 0.24 construction and that a hand-rolled oklch(...) at a call site is forbidden. Five live Astryx mount seams still re-declare it at 0.12:

.maka-model-selection-controls,
.maka-composer-workspace,
.maka-composer-plus-menu,
.permissionModeIcon,
.maka-inspector-panel {
  --color-accent-muted: oklch(from var(--accent) l c h / 0.12);
}

Those subtrees never inherit the unlayered 0.24 bridge, so composer and inspector selection / muted fills stay a second rung. Either drop the local muted pin so they inherit, or record the exemption the way the about-page brand plate is recorded.

简体中文

P2:这里写 --color-accent-muted 只有 0.24 一档。astryx-mount.css 里五处 Astryx 接缝仍钉成 0.12,composer/inspector 不会继承 0.24。删掉这行让它继承,或者像 about 页品牌底板那样把豁免写清楚。

- **The solid tier is deliberately not converged.** `--success` / `--warning` / `--destructive` remain Maka's names for status ink, dots and solid fills, while Astryx calls the same idea `--color-success` / `--color-warning` / `--color-error`. Only the tinted tier has one authority today. Converging the solid tier means moving status text, status dots and status buttons at once; it is a change of its own, not an omission from this one.
- **Identity colors** (avatars, channel marks) live in one 4.2–4.8:1 contrast band; desaturation for muted states happens at constant OKLab lightness.

**The Signal, Not Texture Rule.** Accent communicates action or state. Never use it as a background flood, gradient, glow, or substitute for hierarchy.
Expand All @@ -190,7 +192,7 @@ Use Astryx primitives as the default seam. New work composes product meaning thr
- **Controls:** Maka uses a 20/24/28/32/36/40px height ruler with 32px as the default; Astryx owns the 28/32/36px variants. Hover is restrained; press may use `scale(0.98)`; keyboard focus is always visible. At most one inverted (filled) element per control. Hover washes come in exactly two lanes: product rows and controls take `--state-hover-bg`; chrome that must stay in lockstep with Astryx internals takes `--color-overlay-hover`. Hand-mixed hover alphas are drift.
- **Fields:** labels, descriptions, and validation belong to the field primitive; input focus belongs to its control. Keep disabled reasons discoverable through the owning control's tooltip; do not rebuild field chrome around a bare input.
- **Badges and status:** Badge is 20px high and pill-shaped. Choose variants by meaning, not hue.
- **Status vocabulary:** what a state means is named once, in `packages/ui/src/status-vocabulary.ts`, and every status dot resolves its color through it — a surface never maps its own domain state onto a color. The semantics are `success` (proven healthy), `active` (the system is working), `attention` (waiting on a person), `error` (broken now), `neutral` (a settled fact); collapsing `active` and `attention` is the mistake that vocabulary exists to prevent. It deliberately has no `info` — two callers meant opposite things by it — but that is a statement about *dot semantics*, not about the `--info` color, which is live. Note that Astryx's `Badge` and `StatusDot` render fixed inherited literals, so an Astryx `info` pill and Maka's `--info` are two unrelated blues that merely look alike; a dot's color does not come from the family in §8.
- **Status vocabulary:** what a state means is named once, in `packages/ui/src/status-vocabulary.ts`, and every status dot resolves its color through it — a surface never maps its own domain state onto a color. The semantics are `success` (proven healthy), `active` (the system is working), `attention` (waiting on a person), `error` (broken now), `neutral` (a settled fact); collapsing `active` and `attention` is the mistake that vocabulary exists to prevent. It deliberately has no `info` — two callers meant opposite things by it. Note that Astryx's `Badge` and `StatusDot` render fixed inherited literals, so an Astryx `info` pill does not follow the palette; a dot's color does not come from the family in §8.
- **Counters:** a count is one step smaller and quieter than its label (supporting role, muted ink, `tabular-nums`) and follows its parent's active state back to full ink. Never bolder than the label it counts.
- **Scrollbars:** one app-wide recipe — a 6px pill (10px hit area, 2px transparent inset), thumb at `--border-strong`, one step darker on hover, no painted track. Surfaces may hide their own bars; none may restyle them.
- **Cards:** Astryx Card uses container radius, 12px default padding, and no resting elevation. Astryx components own their geometry.
Expand Down Expand Up @@ -245,7 +247,7 @@ Three mutually exclusive forms, chosen by structural predictability — never by

### Don't (the forbidden list — some items are contract-tested, the rest are review-blocked):

- **Don't** write a bare `oklch()` status color at a call site, or hand-roll a status wash — consume the families (§8).
- **Don't** write a bare `oklch()` status color at a call site, or hand-roll a status tint — consume the families (§8).
- **Don't** use `border-radius: 0` off a full-bleed row (§6).
- **Don't** put an illustration in an empty-state icon slot, add anything past tier 3's single action, or ship a state attribute with no visible feedback (§10).
- **Don't** hardcode `background: white` or any literal surface color — resolve a ladder tier (§2), or `--surface-paper` when the content's own contrast is not ours to control (foreign documents, QR codes) and inverting it would break the content rather than restyle it.
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2847,7 +2847,8 @@ function AppShellContent({
/* Astryx's default: nav column takes --color-background-body, content takes
--color-background-surface. Both point at the product palette through
makaTheme.ts, so the shell follows a palette switch. Declared rather
than defaulted — it decides what separates the two columns. */
than defaulted: the two columns are separated by that background
step alone, so the variant IS the separation. */
variant="elevated"
height="fill"
contentPadding={0}
Expand Down
20 changes: 7 additions & 13 deletions apps/desktop/src/renderer/astryx-theme/maka.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@
--color-success: light-dark(#007004, #9fe59b);
--color-error: light-dark(#a50c25, #ffc6c1);
--color-warning: light-dark(#745b00, #fdcf4f);
--color-success-muted: light-dark(#c5e5c0, #84c9803D);
--color-error-muted: light-dark(#facecb, #ff9e973D);
--color-warning-muted: light-dark(#f8da9d, #deb4333D);
--color-success-muted: oklch(from var(--success) l c h / 0.24);
--color-error-muted: oklch(from var(--destructive) l c h / 0.24);
--color-warning-muted: oklch(from var(--warning) l c h / 0.24);
--color-border: var(--border);
--color-border-emphasized: light-dark(#d4d4d4, #525252);
--color-skeleton: light-dark(#ebebeb, #525252);
Expand Down Expand Up @@ -174,11 +174,11 @@
--color-icon-gray: light-dark(#525252, #a3a3a3);
--color-text-gray: light-dark(#262626, #e5e5e5);
--radius-none: 0px;
--radius-inner: 0.375rem;
--radius-element: 0.625rem;
--radius-container: 0.75rem;
--radius-inner: var(--radius-control);
--radius-element: var(--radius-surface);
--radius-container: var(--radius-modal);
--radius-page: 1.75rem;
--radius-full: 9999px;
--radius-full: var(--radius-pill);
--shadow-low: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 25%)), 0 4px 8px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 40%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 8%));
--shadow-med: 0 2px 4px light-dark(oklch(0 0 0 / 5%), oklch(0 0 0 / 35%)), 0 4px 12px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 12%));
--shadow-high: 0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), 0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%));
Expand Down Expand Up @@ -450,12 +450,6 @@
--astryx-section-padding: var(--spacing-3);
}

.astryx-app-shell-sidenav.elevated {
border-inline-end-width: 1px;
border-inline-end-style: solid;
border-inline-end-color: var(--color-border);
}

.astryx-text.primary { color: var(--color-text-primary); }

.astryx-heading.primary { color: var(--color-text-primary); }
Expand Down
Loading