Design surface: starter roles, theme literals, and the view that could only inspect itself #97

Merged
bvandeusen merged 11 commits from dev into main 2026-08-04 11:02:42 -04:00
2 changed files with 333 additions and 7 deletions
Showing only changes of commit 7b0984579d - Show all commits
+298
View File
@@ -0,0 +1,298 @@
<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"
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
>
<span
v-if="s.shape === 'colour'"
class="tp-swatch"
:style="{ background: 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>
<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">{{ s.declared || "" }}</span>
<span v-if="s.purpose" class="tp-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(--color-text-secondary);
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-sm);
cursor: pointer;
}
.tp-mode:hover { color: var(--color-text); }
.tp-mode.active {
color: var(--color-primary-solid);
border-color: var(--color-primary);
background: var(--color-primary-faint);
}
.tp-modes-note {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
}
.tp-group { margin-bottom: var(--fs-space-5); }
.tp-group-heading {
text-transform: capitalize;
font-size: var(--fs-size-label);
color: var(--color-text-secondary);
margin-bottom: var(--fs-space-2);
}
.tp-grid {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 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. */
.tp-specimen {
height: 2.75rem;
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--fs-radius-sm);
padding: 0 var(--fs-space-2);
overflow: hidden;
/* Checks show through anything translucent — a 15% tint over a solid card
would otherwise look opaque and read as the wrong colour. */
background:
repeating-conic-gradient(var(--color-surface) 0% 25%, var(--color-bg) 0% 50%)
0 0 / 12px 12px;
}
.tp-swatch,
.tp-surface {
width: 100%;
height: 1.75rem;
border-radius: calc(var(--fs-radius-sm) - 1px);
}
.tp-surface { background: var(--color-surface); }
.tp-rule-wrap {
width: 100%;
display: flex;
align-items: center;
}
.tp-rule {
height: 0.5rem;
min-width: 1px;
background: var(--color-primary-solid);
border-radius: 999px;
}
.tp-font {
font-size: 1.4rem;
color: var(--color-text);
line-height: 1;
}
.tp-plain {
font-family: var(--fs-font-mono);
font-size: var(--fs-size-code);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tp-undecided {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
font-style: italic;
}
.tp-name {
font-size: var(--fs-size-body-sm);
color: var(--color-text);
word-break: break-all;
}
.tp-value,
.tp-purpose {
font-size: var(--fs-size-tiny);
color: var(--color-text-muted);
word-break: break-word;
}
</style>
+35 -7
View File
@@ -46,6 +46,7 @@ import {
import { ApiError } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import StarterRolePicker from "@/components/StarterRolePicker.vue";
import TokenPreview from "@/components/TokenPreview.vue";
const toast = useToastStore();
@@ -492,8 +493,23 @@ watch(selectedId, () => {
snippetCheck.value = null;
});
function isColourish(value: string): boolean {
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
/**
* A colour this row can draw HONESTLY — self-contained, no `var()` inside.
*
* The provenance list below shows values as the record states them, and a
* `var()` reference states nothing on its own: rendering
* `color-mix(in srgb, var(--accent) 15%, transparent)` as a background resolves
* `--accent` against THIS app, so a system that isn't the one Scribe runs on
* would be drawn in Scribe's palette. It looked right, which is why it went
* unnoticed (#274).
*
* The preview above resolves values properly, on a probe carrying only that
* system's declarations. So this list draws only what needs no resolving, and
* leaves the rest to the surface built for it.
*/
function isSelfContainedColour(value: string): boolean {
const v = value.trim();
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(v) && !v.includes("var(");
}
</script>
@@ -770,7 +786,7 @@ function isColourish(value: string): boolean {
<ul class="dupe-list">
<li v-for="[value, names] in duplicateEntries" :key="value">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<code>{{ value }}</code> — {{ names.join(", ") }}
@@ -928,7 +944,7 @@ function isColourish(value: string): boolean {
<input v-model="row.mode" class="input mono mode-key" type="text" placeholder="base" />
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
<span
v-if="isColourish(row.value)" class="swatch"
v-if="isSelfContainedColour(row.value)" class="swatch"
:style="{ background: row.value }" aria-hidden="true"
/>
<button
@@ -966,7 +982,7 @@ function isColourish(value: string): boolean {
<span class="token-values">
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
<span
v-if="isColourish(value)" class="swatch"
v-if="isSelfContainedColour(value)" class="swatch"
:style="{ background: value }" aria-hidden="true"
/>
<span class="mode-name">{{ mode }}</span>
@@ -989,6 +1005,18 @@ function isColourish(value: string): boolean {
</ul>
</section>
<!-- What it looks like. Drawn from the record on an isolated probe,
so this is THIS system's palette even when the app around it is
running a different one. -->
<section v-if="resolved.length" class="ds-section">
<h2>Preview</h2>
<p class="section-note">
{{ selected.title }} as it would render — resolved from the record,
not from the stylesheet this app happens to be running.
</p>
<TokenPreview :tokens="resolved" />
</section>
<!-- Effective set -->
<section class="ds-section">
<h2>Effective tokens</h2>
@@ -1022,7 +1050,7 @@ function isColourish(value: string): boolean {
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>
@@ -1043,7 +1071,7 @@ function isColourish(value: string): boolean {
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
<span class="mode-chip">
<span
v-if="isColourish(origin.value)" class="swatch"
v-if="isSelfContainedColour(origin.value)" class="swatch"
:style="{ background: origin.value }" aria-hidden="true"
/>
<span class="mode-name">{{ origin.mode }}</span>