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 { 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>
|
||||
|
||||
Reference in New Issue
Block a user