Design surface: starter roles, theme literals, and the view that could only inspect itself #97
@@ -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>
|
||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
import StarterRolePicker from "@/components/StarterRolePicker.vue";
|
||||||
|
import TokenPreview from "@/components/TokenPreview.vue";
|
||||||
|
|
||||||
const toast = useToastStore();
|
const toast = useToastStore();
|
||||||
|
|
||||||
@@ -492,8 +493,23 @@ watch(selectedId, () => {
|
|||||||
snippetCheck.value = null;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -770,7 +786,7 @@ function isColourish(value: string): boolean {
|
|||||||
<ul class="dupe-list">
|
<ul class="dupe-list">
|
||||||
<li v-for="[value, names] in duplicateEntries" :key="value">
|
<li v-for="[value, names] in duplicateEntries" :key="value">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(value)" class="swatch"
|
v-if="isSelfContainedColour(value)" class="swatch"
|
||||||
:style="{ background: value }" aria-hidden="true"
|
:style="{ background: value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<code>{{ value }}</code> — {{ names.join(", ") }}
|
<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.mode" class="input mono mode-key" type="text" placeholder="base" />
|
||||||
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
<input v-model="row.value" class="input mono" type="text" placeholder="#14171a" />
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(row.value)" class="swatch"
|
v-if="isSelfContainedColour(row.value)" class="swatch"
|
||||||
:style="{ background: row.value }" aria-hidden="true"
|
:style="{ background: row.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -966,7 +982,7 @@ function isColourish(value: string): boolean {
|
|||||||
<span class="token-values">
|
<span class="token-values">
|
||||||
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
|
<span v-for="(value, mode) in token.value_by_mode" :key="mode" class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(value)" class="swatch"
|
v-if="isSelfContainedColour(value)" class="swatch"
|
||||||
:style="{ background: value }" aria-hidden="true"
|
:style="{ background: value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ mode }}</span>
|
<span class="mode-name">{{ mode }}</span>
|
||||||
@@ -989,6 +1005,18 @@ function isColourish(value: string): boolean {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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 -->
|
<!-- Effective set -->
|
||||||
<section class="ds-section">
|
<section class="ds-section">
|
||||||
<h2>Effective tokens</h2>
|
<h2>Effective tokens</h2>
|
||||||
@@ -1022,7 +1050,7 @@ function isColourish(value: string): boolean {
|
|||||||
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
|
<div v-if="hasUniformOrigin(token)" class="resolved-modes">
|
||||||
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
|
<span v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(origin.value)" class="swatch"
|
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||||
:style="{ background: origin.value }" aria-hidden="true"
|
:style="{ background: origin.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ origin.mode }}</span>
|
<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">
|
<div v-for="origin in modeOrigins(token)" :key="origin.mode" class="mode-line">
|
||||||
<span class="mode-chip">
|
<span class="mode-chip">
|
||||||
<span
|
<span
|
||||||
v-if="isColourish(origin.value)" class="swatch"
|
v-if="isSelfContainedColour(origin.value)" class="swatch"
|
||||||
:style="{ background: origin.value }" aria-hidden="true"
|
:style="{ background: origin.value }" aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<span class="mode-name">{{ origin.mode }}</span>
|
<span class="mode-name">{{ origin.mode }}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user