Dim text tokens (base-content/40-50, #4f7099) sat at 3.0-4.1:1 on the navy paper, below the 4.5:1 minimum. Brighten text uses to #7ea6cd (~6:1) and split the overloaded --ink-faint into --ink-dim so borders, stripes and diagram edges keep their intended faintness.
503 lines
13 KiB
Vue
503 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { computed } from "vue"
|
|
|
|
const props = defineProps<{ activeId: string }>()
|
|
|
|
const LABELS = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
|
|
|
|
interface Tile {
|
|
key: string
|
|
label?: string
|
|
skeleton?: boolean
|
|
dim?: boolean
|
|
sel?: boolean
|
|
ghost?: boolean
|
|
check?: boolean
|
|
pos?: string
|
|
hlTag?: string
|
|
}
|
|
interface Toolbar {
|
|
search?: { q?: string; hl?: boolean }
|
|
select?: { meta: string; hl?: boolean }
|
|
badge?: string
|
|
badgeNote?: string
|
|
columnsBadge?: string
|
|
}
|
|
interface Frame {
|
|
kind: "grid" | "display"
|
|
note?: string
|
|
toolbar?: Toolbar
|
|
columns: number
|
|
tiles: Tile[]
|
|
scrollbar?: { hl?: boolean; top: number; h: number }
|
|
footer?: { text: string; hl?: boolean }
|
|
gridTag?: string
|
|
}
|
|
|
|
function tile(i: number, extra: Partial<Tile> = {}): Tile {
|
|
return { key: `t${i}`, label: `Item ${LABELS[i]}`, ...extra }
|
|
}
|
|
function skel(i: number): Tile {
|
|
return { key: `s${i}`, skeleton: true }
|
|
}
|
|
function range(a: number, b: number) {
|
|
const out: number[] = []
|
|
for (let i = a; i <= b; i++) out.push(i)
|
|
return out
|
|
}
|
|
|
|
const COLS = 3
|
|
|
|
const frame = computed<Frame>(() => {
|
|
switch (props.activeId) {
|
|
case "full":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
toolbar: { search: { hl: true }, select: { meta: "multi", hl: true } },
|
|
tiles: [
|
|
tile(0, { hlTag: "◈ DISPLAY" }),
|
|
tile(1),
|
|
tile(2),
|
|
tile(3),
|
|
tile(4),
|
|
tile(5, { pos: "r1·c2", hlTag: "◈ POSITION" }),
|
|
],
|
|
scrollbar: { hl: true, top: 10, h: 46 },
|
|
footer: { text: "↻ loadMore ▾ · items 6 / 48", hl: true },
|
|
}
|
|
case "display":
|
|
return {
|
|
kind: "display",
|
|
columns: COLS,
|
|
note: "Distinct states, cells of equal dimensions. Skeleton cells mirror real cell size so the transition to Idle causes no layout shift.",
|
|
tiles: [],
|
|
}
|
|
case "scroll":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
note: "▓ render window — only these rows exist in the DOM. The row (not the cell) is the unit of virtualization; faded rows release their cell view references.",
|
|
tiles: [
|
|
...range(0, 5).map((i) => tile(i)),
|
|
...range(6, 8).map((i) => tile(i, { ghost: true })),
|
|
],
|
|
scrollbar: { hl: true, top: 28, h: 44 },
|
|
}
|
|
case "load":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
toolbar: { badge: "loadState = LoadingMore", badgeNote: "appending page 2…" },
|
|
tiles: [...range(0, 5).map((i) => tile(i)), skel(6), skel(7), skel(8)],
|
|
footer: { text: "↻ loadMore ▾ · items 6 / 48", hl: true },
|
|
}
|
|
case "position":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
toolbar: { columnsBadge: "columns = 3" },
|
|
note: "row(i) = i / columns · col(i) = i mod columns — each cell's (row, col) is derived from its index; resize(n) reflows.",
|
|
tiles: range(0, 8).map((i) => tile(i, { pos: `r${Math.floor(i / COLS)}·c${i % COLS}` })),
|
|
gridTag: "◈ POSITION",
|
|
}
|
|
case "filter":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
toolbar: { search: { q: '"b"', hl: true } },
|
|
note: 'visible = { x ∈ items | name matches "b" } · the underlying items sequence is untouched.',
|
|
tiles: [
|
|
tile(0, { dim: true }),
|
|
tile(1, { hlTag: "◈ visible" }),
|
|
tile(2, { dim: true }),
|
|
tile(3, { dim: true }),
|
|
tile(4, { dim: true }),
|
|
tile(5, { dim: true }),
|
|
],
|
|
}
|
|
case "select":
|
|
return {
|
|
kind: "grid",
|
|
columns: COLS,
|
|
toolbar: { select: { meta: "MULTI · 2 selected", hl: true } },
|
|
note: "selectionMode = Multi · selected = { Item B, Item E } ⊆ visible",
|
|
tiles: [
|
|
tile(0, { check: false }),
|
|
tile(1, { check: true, sel: true }),
|
|
tile(2, { check: false }),
|
|
tile(3, { check: false }),
|
|
tile(4, { check: true, sel: true }),
|
|
tile(5, { check: false }),
|
|
],
|
|
}
|
|
default:
|
|
return { kind: "grid", columns: COLS, tiles: [] }
|
|
}
|
|
})
|
|
|
|
const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.columns}, 1fr)` }))
|
|
</script>
|
|
|
|
<template>
|
|
<div class="specimen">
|
|
<!-- Display: distinct states -->
|
|
<template v-if="frame.kind === 'display'">
|
|
<p class="note">{{ frame.note }}</p>
|
|
<div class="display-grid">
|
|
<div class="mini">
|
|
<div class="mh">Empty · items = ∅</div>
|
|
<div class="mb">
|
|
<div class="empty-state">
|
|
<div class="big">∅</div>
|
|
no items — valid state
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mini">
|
|
<div class="mh">Loading</div>
|
|
<div class="mb">
|
|
<div class="tilegrid mini-tg">
|
|
<div v-for="i in 4" :key="i" class="tile skeleton"><div class="tskel" /></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mini">
|
|
<div class="mh">Error</div>
|
|
<div class="mb">
|
|
<div class="error-state">⚠ failed to load<br /><span class="retry">↻ retry</span></div>
|
|
</div>
|
|
</div>
|
|
<div class="mini">
|
|
<div class="mh">Idle</div>
|
|
<div class="mb">
|
|
<div class="tilegrid mini-tg">
|
|
<div v-for="i in 4" :key="i" class="tile">
|
|
<div class="tlabel">Item {{ LABELS[i - 1] }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Grid-shaped frames -->
|
|
<template v-else>
|
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
|
|
|
<div v-if="frame.toolbar" class="spec-toolbar">
|
|
<div v-if="frame.toolbar.badge" class="badge">{{ frame.toolbar.badge }}</div>
|
|
<span v-if="frame.toolbar.badgeNote" class="badge-note">{{ frame.toolbar.badgeNote }}</span>
|
|
<div v-if="frame.toolbar.columnsBadge" class="badge">{{ frame.toolbar.columnsBadge }}</div>
|
|
<div
|
|
v-if="frame.toolbar.search"
|
|
class="search"
|
|
:class="{ hl: frame.toolbar.search.hl }"
|
|
:data-tag="frame.toolbar.search.hl ? '◈ FILTER' : undefined"
|
|
>
|
|
⌕
|
|
<span v-if="frame.toolbar.search.q" class="q">{{ frame.toolbar.search.q }}</span>
|
|
<span v-else class="ph">search…</span>
|
|
</div>
|
|
<div
|
|
v-if="frame.toolbar.select"
|
|
class="ctl"
|
|
:class="frame.toolbar.select.hl ? ['hl', 'tag-r'] : []"
|
|
:data-tag="frame.toolbar.select.hl ? '◈ SELECT' : undefined"
|
|
>
|
|
☰ {{ frame.toolbar.select.meta }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="gridbox" :class="frame.gridTag ? ['hl', 'tag-b'] : []" :data-tag="frame.gridTag">
|
|
<div class="gridwrap">
|
|
<div class="tilegrid" :style="gridStyle">
|
|
<div
|
|
v-for="t in frame.tiles"
|
|
:key="t.key"
|
|
class="tile"
|
|
:class="{
|
|
dim: t.dim,
|
|
sel: t.sel,
|
|
ghost: t.ghost,
|
|
skeleton: t.skeleton,
|
|
hl: t.hlTag,
|
|
}"
|
|
:data-tag="t.hlTag"
|
|
>
|
|
<span v-if="t.check !== undefined" class="chk" :class="{ on: t.check }">{{
|
|
t.check ? "✓" : ""
|
|
}}</span>
|
|
<div v-if="t.skeleton" class="tskel" />
|
|
<template v-else>
|
|
<div class="tlabel">{{ t.label }}</div>
|
|
<div v-if="t.pos" class="tpos">{{ t.pos }}</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="frame.scrollbar"
|
|
class="scrollbar"
|
|
:class="frame.scrollbar.hl ? ['hl', 'tag-r'] : []"
|
|
:data-tag="frame.scrollbar.hl ? '◈ SCROLL' : undefined"
|
|
>
|
|
<div
|
|
class="thumb"
|
|
:style="{ top: frame.scrollbar.top + '%', height: frame.scrollbar.h + '%' }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="frame.footer"
|
|
class="footer-load"
|
|
:class="frame.footer.hl ? ['hl', 'tag-b'] : []"
|
|
:data-tag="frame.footer.hl ? '◈ LOAD' : undefined"
|
|
>
|
|
{{ frame.footer.text }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.specimen {
|
|
--ink: var(--color-base-content);
|
|
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
|
|
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
|
|
--amber: var(--color-accent);
|
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
|
--cyan: var(--color-secondary);
|
|
--red: var(--color-error);
|
|
--paper: var(--color-base-100);
|
|
--line: rgba(200, 226, 255, 0.14);
|
|
--line-strong: rgba(200, 226, 255, 0.3);
|
|
font-size: 13px;
|
|
}
|
|
.note {
|
|
color: var(--ink-dim);
|
|
font-size: 11px;
|
|
font-style: italic;
|
|
margin: 0 0 10px;
|
|
}
|
|
.spec-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 10px;
|
|
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
|
font-size: 12px;
|
|
}
|
|
.badge-note {
|
|
color: var(--ink-dim);
|
|
}
|
|
.search {
|
|
flex: 1;
|
|
border: 1px solid var(--line);
|
|
background: var(--paper);
|
|
padding: 5px 9px;
|
|
color: var(--ink);
|
|
}
|
|
.ph {
|
|
color: var(--ink-dim);
|
|
}
|
|
.q {
|
|
color: var(--amber);
|
|
}
|
|
.ctl {
|
|
border: 1px solid var(--line);
|
|
padding: 5px 9px;
|
|
white-space: nowrap;
|
|
}
|
|
.badge {
|
|
border: 1px solid var(--line-strong);
|
|
color: var(--cyan);
|
|
padding: 1px 7px;
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
}
|
|
.gridbox {
|
|
border: 1px solid var(--line);
|
|
background: var(--paper);
|
|
position: relative;
|
|
}
|
|
.gridwrap {
|
|
display: flex;
|
|
}
|
|
.tilegrid {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: grid;
|
|
gap: 8px;
|
|
padding: 10px;
|
|
}
|
|
.tile {
|
|
border: 1px dashed var(--line-strong);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
aspect-ratio: 4 / 3;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
gap: 4px;
|
|
}
|
|
.tlabel {
|
|
color: var(--ink);
|
|
font-size: 12px;
|
|
}
|
|
.tpos {
|
|
color: var(--cyan);
|
|
font-size: 10px;
|
|
}
|
|
.chk {
|
|
position: absolute;
|
|
top: 5px;
|
|
left: 5px;
|
|
width: 14px;
|
|
height: 14px;
|
|
border: 1px solid var(--ink-faint);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 11px;
|
|
color: var(--amber);
|
|
}
|
|
.chk.on {
|
|
border-color: var(--amber);
|
|
background: var(--amber-dim);
|
|
}
|
|
.tile.dim {
|
|
opacity: 0.3;
|
|
}
|
|
.tile.dim .tlabel {
|
|
text-decoration: line-through;
|
|
}
|
|
.tile.sel {
|
|
background: var(--amber-dim);
|
|
border-color: var(--amber);
|
|
border-style: solid;
|
|
}
|
|
.tile.ghost {
|
|
opacity: 0.32;
|
|
background: rgba(143, 208, 255, 0.05);
|
|
}
|
|
.tile.skeleton {
|
|
border-style: solid;
|
|
}
|
|
.tskel {
|
|
width: 60%;
|
|
height: 10px;
|
|
background: repeating-linear-gradient(
|
|
90deg,
|
|
var(--ink-faint),
|
|
var(--ink-faint) 6px,
|
|
transparent 6px,
|
|
transparent 12px
|
|
);
|
|
opacity: 0.5;
|
|
border-radius: 2px;
|
|
}
|
|
.scrollbar {
|
|
width: 8px;
|
|
flex: none;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border-left: 1px solid var(--line);
|
|
position: relative;
|
|
}
|
|
.scrollbar .thumb {
|
|
position: absolute;
|
|
left: 1px;
|
|
right: 1px;
|
|
background: var(--ink-faint);
|
|
opacity: 0.6;
|
|
}
|
|
.footer-load {
|
|
padding: 8px 11px;
|
|
border-top: 1px solid var(--line);
|
|
text-align: center;
|
|
color: var(--cyan);
|
|
font-size: 11px;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.display-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
}
|
|
.mini {
|
|
border: 1px solid var(--line);
|
|
background: var(--paper);
|
|
}
|
|
.mini .mh {
|
|
padding: 4px 8px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 10px;
|
|
letter-spacing: 0.12em;
|
|
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
|
text-transform: uppercase;
|
|
}
|
|
.mini .mb {
|
|
padding: 8px;
|
|
}
|
|
.mini-tg {
|
|
grid-template-columns: 1fr 1fr;
|
|
padding: 0;
|
|
}
|
|
.mini-tg .tile {
|
|
aspect-ratio: 3 / 2;
|
|
}
|
|
.mini-tg .tlabel {
|
|
font-size: 11px;
|
|
}
|
|
.empty-state {
|
|
padding: 16px 12px;
|
|
text-align: center;
|
|
color: var(--ink-dim);
|
|
}
|
|
.empty-state .big {
|
|
font-size: 22px;
|
|
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
|
}
|
|
.error-state {
|
|
padding: 16px 12px;
|
|
text-align: center;
|
|
color: var(--red);
|
|
line-height: 1.9;
|
|
}
|
|
.error-state .retry {
|
|
color: var(--cyan);
|
|
}
|
|
|
|
/* callouts */
|
|
.hl {
|
|
outline: 1.5px dashed var(--amber);
|
|
outline-offset: 3px;
|
|
position: relative;
|
|
z-index: 2;
|
|
}
|
|
.hl[data-tag]::after {
|
|
content: attr(data-tag);
|
|
position: absolute;
|
|
top: -10px;
|
|
left: 8px;
|
|
background: var(--paper);
|
|
color: var(--amber);
|
|
font-size: 9px;
|
|
padding: 0 5px;
|
|
border: 1px solid var(--amber);
|
|
white-space: nowrap;
|
|
letter-spacing: 0.1em;
|
|
line-height: 1.5;
|
|
z-index: 3;
|
|
}
|
|
.hl.tag-r[data-tag]::after {
|
|
left: auto;
|
|
right: 8px;
|
|
}
|
|
.hl.tag-b[data-tag]::after {
|
|
top: auto;
|
|
bottom: -10px;
|
|
}
|
|
</style>
|