CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 17s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 37s
#2533. theme.css claimed "removing this block is a rename sweep across the
components, tracked separately" — written in 67a529a, never filed, which made
the comment itself an instance of the survey's presence-without-reference
pattern. This is that sweep.
73 alias declarations deleted; 69 files rewritten; every --color-*-style name
now references its --fs-* token directly. Mechanical by construction: the map
IS the alias block, applied longest-name-first with a boundary guard so
--color-text never matched inside --color-text-muted. Zero survivors outside
theme.css, verified by grep rather than assumed.
One deliberate survivor: --color-shadow stays DECLARED, because it was never
an alias — it is a literal value the design system has no token for. Marked
in place as a recorded gap: promote it to an --fs-* token when a second app
needs it, don't copy the line.
Nothing is lost mode-wise: the aliases' resolve-at-use-time trick (which
absorbed 48 dark-mode overrides) lives one layer down in the --fs-* tokens'
own derivations, which is why the sweep is a pure rename. Both CSS checkers
green.
Why now rather than never: check_snippets_against_design_system reports every
--color-* reference as "unknown — renders as NOTHING", and nine recipe
snippets recorded from components.css carried the deprecated names, making
them prior art pointing the wrong way. With the sweep in, the checker's
report over re-recorded snippets should be EMPTY — the acceptance test that
proves the checker was right all along (#2517's correction).
Refs #2533
363 lines
11 KiB
Vue
363 lines
11 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* A design system's tokens, drawn rather than listed (#2431).
|
|
*
|
|
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
|
|
* Every value is resolved on an offscreen probe carrying only this system's
|
|
* declarations (`resolveDeclared`), never read from the page. So a token like
|
|
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
|
|
* accent, not the accent of the app you happen to be looking at. Previewing
|
|
* another project's palette from here is the point; a preview that quietly
|
|
* borrows the host app's values would be worse than no preview, because it
|
|
* would look right.
|
|
*
|
|
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
|
|
* A colour is drawn as a swatch, a length as a rule of that length, a font
|
|
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
|
|
* naming convention, because the convention is the install's (rule #115) — a
|
|
* system that calls its spacing `--gap-N` gets the same treatment.
|
|
*
|
|
* A token with no value for the chosen mode is shown as undecided rather than
|
|
* skipped. A named role awaiting a decision is information; a gap in a grid
|
|
* is not.
|
|
*/
|
|
import { computed, ref, watch } from "vue";
|
|
|
|
import type { ResolvedToken } from "@/api/designSystems";
|
|
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
|
|
|
|
const props = defineProps<{ tokens: ResolvedToken[] }>();
|
|
|
|
const modes = computed(() => modesPresent(props.tokens));
|
|
const mode = ref(BASE_MODE);
|
|
|
|
/** Values as the browser would compute them, for the chosen mode. */
|
|
const rendered = ref<Map<string, string>>(new Map());
|
|
|
|
function recompute() {
|
|
const declared = new Map<string, string>();
|
|
for (const token of props.tokens) {
|
|
const value = valueForMode(token.value_by_mode, mode.value);
|
|
if (value) declared.set(token.name, value);
|
|
}
|
|
rendered.value = resolveDeclared(declared);
|
|
}
|
|
|
|
watch(
|
|
[() => props.tokens, mode],
|
|
() => {
|
|
// Keep the selection only while it still exists — switching systems can
|
|
// drop a mode, and a stale one would silently render as base.
|
|
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
|
|
recompute();
|
|
},
|
|
{ immediate: true, deep: false },
|
|
);
|
|
|
|
type Shape = "colour" | "surface" | "length" | "font" | "plain";
|
|
|
|
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
|
|
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
|
|
const GRADIENT = /gradient\(/;
|
|
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
|
|
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
|
|
/** A stack of family names: commas, no functions, no digits. */
|
|
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
|
|
|
|
function shapeOf(value: string): Shape {
|
|
const v = value.trim();
|
|
if (!v) return "plain";
|
|
if (COLOUR.test(v)) return "colour";
|
|
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
|
|
if (LENGTH.test(v)) return "length";
|
|
if (FONT_STACK.test(v)) return "font";
|
|
return "plain";
|
|
}
|
|
|
|
interface Specimen {
|
|
name: string;
|
|
declared: string;
|
|
rendered: string;
|
|
shape: Shape;
|
|
purpose: string | null;
|
|
/** True when `var()` substitution changed the value — worth showing on hover. */
|
|
substituted: boolean;
|
|
}
|
|
|
|
const groups = computed(() => {
|
|
const out = new Map<string, Specimen[]>();
|
|
for (const token of props.tokens) {
|
|
const declared = valueForMode(token.value_by_mode, mode.value);
|
|
const value = rendered.value.get(token.name) ?? "";
|
|
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
|
|
bucket.push({
|
|
name: token.name,
|
|
declared,
|
|
rendered: value,
|
|
shape: shapeOf(value),
|
|
purpose: token.purpose,
|
|
substituted: Boolean(declared) && value !== declared,
|
|
});
|
|
out.set(token.group_name ?? "ungrouped", bucket);
|
|
}
|
|
return [...out.entries()];
|
|
});
|
|
|
|
/**
|
|
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
|
|
* are visibly different — but a stray `100vw` can't stretch the row.
|
|
*/
|
|
function ruleWidth(value: string): string {
|
|
return `min(${value}, 12rem)`;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="tp">
|
|
<div v-if="modes.length > 1" class="tp-modes">
|
|
<button
|
|
v-for="m in modes"
|
|
:key="m"
|
|
class="tp-mode"
|
|
:class="{ active: m === mode }"
|
|
@click="mode = m"
|
|
>{{ m }}</button>
|
|
<span class="tp-modes-note">
|
|
The system's own modes — independent of the theme this app is in.
|
|
</span>
|
|
</div>
|
|
|
|
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
|
|
<h3 class="tp-group-heading">{{ group }}</h3>
|
|
<ul class="tp-grid">
|
|
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
|
<div
|
|
class="tp-specimen"
|
|
:class="`is-${s.shape}`"
|
|
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
|
>
|
|
<span
|
|
v-if="s.shape === 'colour'"
|
|
class="tp-swatch"
|
|
:style="{ '--tp-fill': s.rendered }"
|
|
/>
|
|
<span
|
|
v-else-if="s.shape === 'surface'"
|
|
class="tp-surface"
|
|
:style="s.rendered.includes('gradient(')
|
|
? { background: s.rendered }
|
|
: { boxShadow: s.rendered }"
|
|
/>
|
|
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
|
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
|
<span class="tp-rule-label">{{ s.rendered }}</span>
|
|
</span>
|
|
<span
|
|
v-else-if="s.shape === 'font'"
|
|
class="tp-font"
|
|
:style="{ fontFamily: s.rendered }"
|
|
>Ag</span>
|
|
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
|
|
<span v-else class="tp-plain">{{ s.rendered }}</span>
|
|
</div>
|
|
|
|
<code class="tp-name">{{ s.name }}</code>
|
|
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
|
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.tp-modes {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--fs-space-2);
|
|
flex-wrap: wrap;
|
|
margin-bottom: var(--fs-space-4);
|
|
}
|
|
|
|
.tp-mode {
|
|
padding: 0.2rem 0.6rem;
|
|
font: inherit;
|
|
font-size: var(--fs-size-body-sm);
|
|
color: var(--fs-text-secondary);
|
|
background: transparent;
|
|
border: 1px solid var(--fs-border-color);
|
|
border-radius: var(--fs-radius-sm);
|
|
cursor: pointer;
|
|
}
|
|
.tp-mode:hover { color: var(--fs-text-primary); }
|
|
.tp-mode.active {
|
|
color: var(--fs-accent);
|
|
border-color: var(--fs-accent);
|
|
background: var(--fs-accent-faint);
|
|
}
|
|
|
|
.tp-modes-note {
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
|
|
.tp-group { margin-bottom: var(--fs-space-6); }
|
|
|
|
.tp-group-heading {
|
|
text-transform: uppercase;
|
|
letter-spacing: var(--fs-tracking-tiny);
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary);
|
|
margin: 0 0 var(--fs-space-3);
|
|
padding-bottom: var(--fs-space-2);
|
|
border-bottom: var(--fs-border);
|
|
}
|
|
|
|
.tp-grid {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
|
gap: var(--fs-space-4) var(--fs-space-3);
|
|
}
|
|
|
|
.tp-item {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.15rem;
|
|
}
|
|
|
|
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
|
|
*
|
|
* The stage itself is plain. An earlier version put the checkerboard here, so
|
|
* every specimen — including opaque colours and plain text — sat inside a
|
|
* frame of checks, and the pattern read as the loudest thing on the page. The
|
|
* checks belong to the ONE case that needs them: a colour that might be
|
|
* translucent. */
|
|
.tp-specimen {
|
|
height: 2.5rem;
|
|
display: flex;
|
|
align-items: center;
|
|
border-radius: var(--fs-radius-sm);
|
|
padding: var(--fs-space-1);
|
|
overflow: hidden;
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
|
|
/* Text-bearing specimens get no box at all — a border around a value is a
|
|
frame around nothing, which is most of what made the grid feel busy. */
|
|
.tp-specimen.is-plain,
|
|
.tp-specimen.is-length,
|
|
.tp-specimen.is-font {
|
|
background: none;
|
|
padding: 0 var(--fs-space-1);
|
|
}
|
|
|
|
/* Checks UNDER the colour, not around it: an opaque value hides them
|
|
completely, and a 15% tint shows exactly as much of them as it should.
|
|
Layering the fill as a gradient is what lets one element do both. */
|
|
.tp-swatch {
|
|
/* Declared here, overridden inline per swatch. Two reasons it is a real
|
|
default rather than a formality: a token that resolves to nothing renders
|
|
as bare checks instead of an invalid gradient, and a custom property that
|
|
exists ONLY as an inline style is invisible to the CI token check — which
|
|
reads it as an unresolvable reference, correctly, since nothing in any
|
|
stylesheet declares it. */
|
|
--tp-fill: transparent;
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
|
background-image:
|
|
linear-gradient(var(--tp-fill), var(--tp-fill)),
|
|
repeating-conic-gradient(
|
|
var(--fs-border-color) 0% 25%,
|
|
var(--fs-surface-raised) 0% 50%
|
|
);
|
|
background-size: auto, 10px 10px;
|
|
}
|
|
|
|
.tp-surface {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
|
background: var(--fs-surface-raised);
|
|
}
|
|
|
|
.tp-rule-wrap {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--fs-space-2);
|
|
min-width: 0;
|
|
}
|
|
|
|
.tp-rule {
|
|
height: 0.4rem;
|
|
min-width: 1px;
|
|
flex: none;
|
|
background: var(--fs-accent);
|
|
border-radius: 999px;
|
|
}
|
|
|
|
.tp-rule-label {
|
|
font-family: var(--fs-font-mono);
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tp-font {
|
|
font-size: 1.4rem;
|
|
color: var(--fs-text-primary);
|
|
line-height: 1;
|
|
}
|
|
|
|
.tp-plain {
|
|
font-family: var(--fs-font-mono);
|
|
font-size: var(--fs-size-code);
|
|
color: var(--fs-text-secondary);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.tp-undecided {
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary);
|
|
font-style: italic;
|
|
}
|
|
|
|
/* One line each, with the full text on hover.
|
|
*
|
|
* These wrapped freely at first, so a card was two lines tall or five depending
|
|
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
|
|
* which is most of what "messy" was. A derived value is not something anyone
|
|
* reads character by character in a gallery; it is something you check the
|
|
* shape of and open if it matters. */
|
|
.tp-name,
|
|
.tp-value,
|
|
.tp-purpose {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tp-name {
|
|
font-size: var(--fs-size-body-sm);
|
|
color: var(--fs-text-primary);
|
|
}
|
|
|
|
.tp-value {
|
|
font-family: var(--fs-font-mono);
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-secondary);
|
|
}
|
|
|
|
.tp-purpose {
|
|
font-size: var(--fs-size-tiny);
|
|
color: var(--fs-text-tertiary);
|
|
}
|
|
</style>
|