@0001-labs/design-system-1 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/DS1/0-face/constraint-panel.ts +27 -6
  2. package/DS1/0-face/constraint-site.ts +3 -2
  3. package/DS1/0-face/constraints.ts +27 -0
  4. package/DS1/0-face/define.ts +1 -1
  5. package/DS1/0-face/fault-lines.ts +159 -0
  6. package/DS1/1-root/cjk.css +1 -1
  7. package/DS1/1-root/fonts.css +1 -1
  8. package/DS1/1-root/one.css +3 -2
  9. package/DS1/2-core/ds-avatar.ts +47 -8
  10. package/DS1/2-core/styles/ds-button-host.css +2 -1
  11. package/DS1/2-core/styles/ds-button.css +2 -1
  12. package/DS1/2-core/styles/ds-tag.css +2 -1
  13. package/DS1/3-unit/context-trigger.ts +29 -2
  14. package/DS1/3-unit/ds-form.ts +2 -1
  15. package/DS1/3-unit/ds-table.ts +2 -1
  16. package/DS1/4-page/ds-container.ts +2 -1
  17. package/dist/0-face/constraint-panel.d.ts.map +1 -1
  18. package/dist/0-face/constraint-panel.js +28 -6
  19. package/dist/0-face/constraint-site.d.ts +1 -0
  20. package/dist/0-face/constraint-site.d.ts.map +1 -1
  21. package/dist/0-face/constraint-site.js +3 -2
  22. package/dist/0-face/constraints.d.ts.map +1 -1
  23. package/dist/0-face/constraints.js +27 -0
  24. package/dist/0-face/define.js +1 -1
  25. package/dist/0-face/fault-lines.d.ts +20 -0
  26. package/dist/0-face/fault-lines.d.ts.map +1 -0
  27. package/dist/0-face/fault-lines.js +172 -0
  28. package/dist/2-core/ds-avatar.d.ts +5 -0
  29. package/dist/2-core/ds-avatar.d.ts.map +1 -1
  30. package/dist/2-core/ds-avatar.js +53 -9
  31. package/dist/3-unit/context-trigger.d.ts.map +1 -1
  32. package/dist/3-unit/context-trigger.js +32 -2
  33. package/dist/3-unit/ds-form.d.ts.map +1 -1
  34. package/dist/3-unit/ds-form.js +2 -1
  35. package/dist/3-unit/ds-table.d.ts.map +1 -1
  36. package/dist/3-unit/ds-table.js +2 -1
  37. package/dist/4-page/ds-container.d.ts.map +1 -1
  38. package/dist/4-page/ds-container.js +2 -1
  39. package/dist/design-system-1.bundle.js +372 -32
  40. package/dist/design-system-1.bundle.js.map +1 -1
  41. package/dist/design-system-1.bundle.min.js +3380 -3128
  42. package/dist/design-system-1.bundle.min.js.map +1 -1
  43. package/package.json +6 -4
@@ -1,5 +1,6 @@
1
+ import { paintFaultLines } from './fault-lines';
1
2
  import { measureConstraints, type Constraint, type Finding } from './constraints';
2
- import { PHONE_WIDTHS, checkSite, findSitePages, inspectInFrame, ruleLine, summarizePages, summarizeRules, type PageResult } from './constraint-site';
3
+ import { VIEWPORT_WIDTHS, checkSite, findSitePages, inspectInFrame, ruleLine, summarizePages, summarizeRules, type PageResult } from './constraint-site';
3
4
 
4
5
  // appendChild, not append: a Worker project's types redefine append for its HTML rewriter.
5
6
 
@@ -38,7 +39,8 @@ export function createConstraintPanel() {
38
39
  --danger: var(--ds-danger-color, light-dark(#de0331, #ff5f5f));
39
40
  }
40
41
  :host([hidden]) { display: none !important; }
41
- .rings { position: absolute; inset: 0; overflow: hidden; }
42
+ .fault-lines { z-index: 0 !important; }
43
+ .rings { z-index: 1; position: absolute; inset: 0; overflow: hidden; }
42
44
  .frames { position: absolute; width: 0; height: 0; overflow: hidden; }
43
45
  .ring {
44
46
  position: absolute;
@@ -60,6 +62,7 @@ export function createConstraintPanel() {
60
62
  text-align: center;
61
63
  }
62
64
  aside {
65
+ z-index: 2;
63
66
  position: absolute;
64
67
  inset-block: 0;
65
68
  inset-inline-end: 0;
@@ -106,14 +109,18 @@ export function createConstraintPanel() {
106
109
  .code b { color: var(--danger); font-weight: 400; }
107
110
  .rule { color: var(--muted); }
108
111
  p { margin: 0; }
112
+ :host([data-lines-only]) aside { inset-block: 0 auto; width: auto; padding: calc(var(--u) / 2); }
113
+ :host([data-lines-only]) aside > :not(header), :host([data-lines-only]) header > :not([data-lines], [data-close]) { display: none; }
114
+ :host([data-lines-only]) header { margin: 0; }
109
115
  @media print { :host { display: none !important; } }
110
- </style><div class="rings"></div><div class="frames"></div><aside aria-label="Constraints">
111
- <header><strong>Constraints</strong><button type="button" data-run>Page</button><button type="button" data-site>Site</button><button type="button" data-close aria-label="Close">Close</button></header>
112
- <p data-status aria-live="polite"></p>
116
+ </style><svg class="fault-lines" aria-hidden="true"></svg><div class="rings"></div><div class="frames"></div><aside aria-label="Constraints">
117
+ <header><strong>Constraints</strong><button type="button" data-run>Page</button><button type="button" data-site>Site</button><button type="button" data-lines>Lines</button><button type="button" data-close aria-label="Close">Close</button></header>
118
+ <p data-status aria-live="polite"></p><p class="rule">Fault lines: dashed reference · red drift</p>
113
119
  <ol data-findings></ol>
114
120
  <section data-rules hidden><h3>Rules</h3><ol></ol></section>
115
121
  <section data-pages hidden><h3>Pages</h3><ol></ol></section>
116
122
  </aside>`;
123
+ const faultLines = shadow.querySelector<SVGSVGElement>('.fault-lines')!;
117
124
  const rings = shadow.querySelector<HTMLDivElement>('.rings')!;
118
125
  const list = shadow.querySelector<HTMLOListElement>('[data-findings]')!;
119
126
  const rulesSection = shadow.querySelector<HTMLElement>('[data-rules]')!;
@@ -123,6 +130,9 @@ export function createConstraintPanel() {
123
130
  const status = shadow.querySelector<HTMLElement>('[data-status]')!;
124
131
  const title = shadow.querySelector('strong')!;
125
132
  const frames = shadow.querySelector<HTMLDivElement>('.frames')!;
133
+ const linesButton = shadow.querySelector<HTMLButtonElement>('[data-lines]')!;
134
+ const setLinesOnly = (enabled: boolean) => { panel.toggleAttribute('data-lines-only', enabled); linesButton.textContent = enabled ? 'Results' : 'Lines'; };
135
+ linesButton.addEventListener('click', () => setLinesOnly(!panel.hasAttribute('data-lines-only')));
126
136
  const siteButton = shadow.querySelector<HTMLButtonElement>('[data-site]')!;
127
137
  let site: AbortController | null = null;
128
138
  let findings: (Finding & { constraint: Constraint })[] = [];
@@ -131,6 +141,7 @@ export function createConstraintPanel() {
131
141
 
132
142
  const place = () => {
133
143
  frame = 0;
144
+ paintFaultLines(document, faultLines);
134
145
  rings.replaceChildren(...findings.map(({ element }, index) => {
135
146
  const rect = element.getBoundingClientRect();
136
147
  const ring = document.createElement('div');
@@ -144,6 +155,8 @@ export function createConstraintPanel() {
144
155
  }));
145
156
  };
146
157
  const schedule = () => { if (!frame) frame = requestAnimationFrame(place); };
158
+ const layoutChanges = new MutationObserver(schedule);
159
+ const sizeChanges = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
147
160
 
148
161
  const stopSite = () => {
149
162
  site?.abort();
@@ -276,7 +289,7 @@ export function createConstraintPanel() {
276
289
  title.textContent = 'Constraints';
277
290
  status.textContent = 'Finding the pages…';
278
291
  const pages = await findSitePages();
279
- const widths = `${PHONE_WIDTHS.slice(0, -1).join(', ')} and ${PHONE_WIDTHS[PHONE_WIDTHS.length - 1]}px`;
292
+ const widths = `${VIEWPORT_WIDTHS.slice(0, -1).join(', ')} and ${VIEWPORT_WIDTHS[VIEWPORT_WIDTHS.length - 1]}px`;
280
293
  let broken = 0;
281
294
  const results = await checkSite({
282
295
  pages,
@@ -302,13 +315,21 @@ export function createConstraintPanel() {
302
315
  const show = (mode: 'page' | 'site' = 'page') => {
303
316
  if (!panel.isConnected) document.body.appendChild(panel);
304
317
  panel.hidden = false;
318
+ setLinesOnly(window.innerWidth < 640);
305
319
  if (mode === 'site') void runSite(); else run();
320
+ layoutChanges.observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['style','class','hidden','data-ds-fault-lines','data-ds-fault-axis']});
321
+ sizeChanges?.observe(document.body);
306
322
  window.addEventListener('scroll', schedule, { passive: true, capture: true });
307
323
  window.addEventListener('resize', schedule);
308
324
  };
309
325
  const hide = () => {
310
326
  stopSite();
311
327
  panel.hidden = true;
328
+ if (frame) cancelAnimationFrame(frame);
329
+ frame = 0;
330
+ faultLines.replaceChildren();
331
+ layoutChanges.disconnect();
332
+ sizeChanges?.disconnect();
312
333
  window.removeEventListener('scroll', schedule, { capture: true });
313
334
  window.removeEventListener('resize', schedule);
314
335
  };
@@ -8,6 +8,7 @@ import { calculateScalingFactor, defaultScalingConfig } from './scaling';
8
8
 
9
9
  /** The narrowest phone still sold, the common ones and the widest. */
10
10
  export const PHONE_WIDTHS = [320, 375, 393, 430];
11
+ export const VIEWPORT_WIDTHS = [...PHONE_WIDTHS, 768, 1024, 1440];
11
12
  /** Pages checked side by side, each in its own frame. */
12
13
  export const PAGES_AT_ONCE = 3;
13
14
 
@@ -61,7 +62,7 @@ const wait = (ms: number) => new Promise(done => setTimeout(done, ms));
61
62
  * order of `widths`. The page reflows at each width without loading again. The address carries `ph=out`,
62
63
  * the analytics opt-out most trackers honour, so the check counts no visits.
63
64
  */
64
- export async function inspectInFrame(host: Element | ShadowRoot, url: string, widths: number[] = PHONE_WIDTHS, height = 844): Promise<PageCheck[]> {
65
+ export async function inspectInFrame(host: Element | ShadowRoot, url: string, widths: number[] = VIEWPORT_WIDTHS, height = 844): Promise<PageCheck[]> {
65
66
  const frame = document.createElement('iframe');
66
67
  frame.setAttribute('aria-hidden', 'true');
67
68
  frame.tabIndex = -1;
@@ -114,7 +115,7 @@ export function checkPage(page: ParentNode): PageCheck {
114
115
  * each width as it comes. Stops starting pages when `signal` aborts. Whatever the pages store in this
115
116
  * browser while they are checked is put back afterwards.
116
117
  */
117
- export async function checkSite({ pages, widths = PHONE_WIDTHS, inspect, onResult, signal, atOnce = PAGES_AT_ONCE }: {
118
+ export async function checkSite({ pages, widths = VIEWPORT_WIDTHS, inspect, onResult, signal, atOnce = PAGES_AT_ONCE }: {
118
119
  pages: string[];
119
120
  widths?: number[];
120
121
  /** One page at every width, in the order of `widths`. */
@@ -1,3 +1,4 @@
1
+ import { faultLineGrids, inspectFaultLines } from './fault-lines';
1
2
  import { labelledButtons, inspectButtonLabels } from './button-label';
2
3
  import { inspectTextContrast, measureTextContrast } from "./text-contrast";
3
4
  import { inspectWordWrap, wordHeaders } from './word-wrap';
@@ -35,6 +36,14 @@ export type Constraint = {
35
36
  count: (root: ParentNode) => number;
36
37
  };
37
38
 
39
+ /** The actual initial, including a component nested in another open shadow root. */
40
+ function isAvatarInitial(element: Element): boolean {
41
+ for (let parent: Element | null = element; parent; parent = parent.assignedSlot ?? parent.parentElement ?? (parent.getRootNode() as ShadowRoot).host ?? null) {
42
+ if (parent.matches('ds-avatar, .ds-avatar')) return true;
43
+ }
44
+ return false;
45
+ }
46
+
38
47
  /** A pixel either way is the same place. */
39
48
  const near = (a: number, b: number) => Math.abs(a - b) <= 1;
40
49
  const px = (n: number) => `${Math.round(n)}px`;
@@ -103,6 +112,14 @@ function rows(found: ReturnType<typeof cards>) {
103
112
  }
104
113
 
105
114
  export const constraints: Constraint[] = [
115
+ {
116
+ code: 'page.fault-lines',
117
+ rule: 'Fault lines keep related card and control grids aligned across the whole page, with matching track edges and gaps at every checked viewport width.',
118
+ why: 'Compare separate sections in the same alignment family; responsive layouts may change together, while unrelated groups and intentional exceptions remain independent.',
119
+ components: ['ds-page'],
120
+ check: { file: 'tests/fault-lines.test.ts', test: 'fault lines catch shifted tracks and unequal gaps across sections at each viewport' },
121
+ measures: ['alignment group', 'alignment groups'], count: root => faultLineGrids(root).length, inspect: inspectFaultLines,
122
+ },
106
123
  {
107
124
  code: 'select.hug',
108
125
  rule: 'Select and date-picker triggers use hug sizing, never fill sizing or growing to occupy the available row.',
@@ -146,6 +163,16 @@ export const constraints: Constraint[] = [
146
163
  count: root => measureTextContrast(root).length,
147
164
  inspect: inspectTextContrast,
148
165
  },
166
+ {
167
+ code: 'avatar.initial-contrast',
168
+ rule: 'Avatar initials have at least 4.5:1 contrast against their rendered fill, including after a theme or identity colour changes.',
169
+ why: 'Check each initial inside the avatar’s shadow root and in overlapping groups; decorative initials still need to be readable. Explicit ink overrides must pass too.',
170
+ components: ['ds-avatar'],
171
+ check: { file: 'tests/avatar-contrast.test.ts', test: 'avatar initials stay readable after theme and CSS-variable changes, and the avatar constraint catches overrides' },
172
+ measures: ['avatar initial', 'avatar initials'],
173
+ count: root => measureTextContrast(root).filter(result => isAvatarInitial(result.element)).length,
174
+ inspect: root => inspectTextContrast(root).filter(finding => isAvatarInitial(finding.element)),
175
+ },
149
176
  {
150
177
  code: 'card.media-level',
151
178
  rule: "A card's picture stands on the card's foot, a square below the words at least, so the pictures of cards side by side stand on one level whatever the words above them.",
@@ -13,7 +13,7 @@ export function define(name: string, element: CustomElementConstructor): void {
13
13
  const type = element as StyledConstructor;
14
14
  const prototype = element.prototype as StyledElement;
15
15
  const original = prototype.createRenderRoot;
16
- if (original) {
16
+ if (original && typeof document !== 'undefined') {
17
17
  const definingDocument = document;
18
18
  prototype.createRenderRoot = function (this: StyledElement) {
19
19
  if (this.ownerDocument === definingDocument || !type.elementStyles?.length) return original.call(this);
@@ -0,0 +1,159 @@
1
+ import type { Finding } from './constraints';
2
+
3
+ const tolerance = 1;
4
+ const near = (a: number, b: number) => Math.abs(a - b) <= tolerance;
5
+ const style = (el: Element) => el.ownerDocument.defaultView!.getComputedStyle(el);
6
+ const visible = (el: Element) => {
7
+ const box = el.getBoundingClientRect();
8
+ return box.width > 0 && box.height > 0 && style(el).visibility !== 'hidden' && style(el).display !== 'none';
9
+ };
10
+
11
+ /** Walk each open shadow tree once; slotted children already occur in the light tree. */
12
+ function elements(root: ParentNode): Element[] {
13
+ return Array.from(root.querySelectorAll('*')).flatMap(el => [el, ...(el.shadowRoot ? elements(el.shadowRoot) : [])]);
14
+ }
15
+
16
+ type Grid = { element: Element; family: string | null; axis: 'x' | 'y'; lines: number[]; gaps: number[]; crossGap?: number; internalDrift: boolean; start: number; end: number };
17
+
18
+ /** Related grids opt in by name; unlabelled card grids are candidates for human review. */
19
+ export function faultLineGrids(root: ParentNode): Grid[] {
20
+ return elements(root).flatMap(element => {
21
+ const family = element.getAttribute('data-ds-fault-lines');
22
+ if (family === 'off' || !visible(element)) return [];
23
+ const children = Array.from(element.children).filter(visible);
24
+ if (!children.length) return [];
25
+ if (family === null && (!/grid/.test(style(element).display) || children.length < 2 ||
26
+ !children.every(child => child.matches('ds-card, .ds-card, ds-button, button')))) return [];
27
+ const axis = element.getAttribute('data-ds-fault-axis') === 'y' ? 'y' : 'x';
28
+ const boxes = children.map(child => child.getBoundingClientRect());
29
+ const crossStart = (box: DOMRect) => axis === 'x' ? box.top : box.left;
30
+ const crossEnd = (box: DOMRect) => axis === 'x' ? box.bottom : box.right;
31
+ const start = (box: DOMRect) => axis === 'x' ? box.left : box.top;
32
+ const end = (box: DOMRect) => axis === 'x' ? box.right : box.bottom;
33
+ const bands: DOMRect[][] = [];
34
+ for (const box of boxes.sort((a,b) => crossStart(a)-crossStart(b))) {
35
+ const band = bands.find(row => near(crossStart(row[0]!), crossStart(box)));
36
+ if (band) band.push(box); else bands.push([box]);
37
+ }
38
+ // The fullest band defines the tracks; an incomplete final row is valid.
39
+ const band = [...bands].sort((a,b) => b.length-a.length)[0]!.sort((a,b) => start(a)-start(b));
40
+ const lines = band.flatMap(box => [start(box), end(box)]);
41
+ const gaps = band.slice(1).map((box,index) => start(box)-end(band[index]!));
42
+ const crossGaps = bands.slice(1).map((row,index) => Math.min(...row.map(crossStart))-Math.max(...bands[index]!.map(crossEnd)));
43
+ const internalDrift = bands.some(row => row.length === band.length && row.slice().sort((a,b)=>start(a)-start(b)).some((box,index)=>!near(start(box),lines[index*2]!) || !near(end(box),lines[index*2+1]!))) || crossGaps.some(gap=>!near(gap,crossGaps[0]!));
44
+ return [{element, family, axis, lines, gaps, crossGap: crossGaps[0], internalDrift, start: lines[0]!, end: lines[lines.length - 1]!}];
45
+ });
46
+ }
47
+
48
+ function referenceFor(grids: Grid[], i: number) {
49
+ const current = grids[i]!;
50
+ return grids.slice(0,i).find(other => {
51
+ if (other.axis !== current.axis || other.lines.length !== current.lines.length) return false;
52
+ if (current.family !== null || other.family !== null) return current.family !== null && current.family === other.family;
53
+ return near(current.start,other.start) && near(current.end,other.end);
54
+ });
55
+ }
56
+
57
+ /** Fault lines compare rendered tracks across separate sections, in the current viewport. */
58
+ export function inspectFaultLines(root: ParentNode): Finding[] {
59
+ const grids = faultLineGrids(root);
60
+ const findings: Finding[] = [];
61
+ for (let i = 0; i < grids.length; i++) {
62
+ const current = grids[i]!;
63
+ if (current.internalDrift) findings.push({element:current.element, ...(current.family === null ? {review:true} : {}), message:`Fault lines at ${current.element.ownerDocument.documentElement.clientWidth}px: tracks or repeated gaps drift inside ${current.family || 'this card grid'}. Align its complete rows and repeated spacing.`});
64
+ const reference = referenceFor(grids, i);
65
+ if (!reference) continue;
66
+ const drift = current.lines.map((line,index) => Math.abs(line-reference.lines[index]!));
67
+ const gapDrift = current.gaps.some((gap,index) => !near(gap,reference.gaps[index]!));
68
+ const rowDrift = current.crossGap !== undefined && reference.crossGap !== undefined && !near(current.crossGap,reference.crossGap);
69
+ if (!drift.some(value => value > tolerance) && !gapDrift && !rowDrift) continue;
70
+ const width = current.element.ownerDocument.documentElement.clientWidth;
71
+ const review = current.family === null;
72
+ findings.push({element: current.element, ...(review ? {review:true} : {}), message:
73
+ `Fault lines at ${width}px: ${current.family || 'these card grids'} ${review ? 'may be related but have' : 'have'} misaligned ${current.axis === 'x' ? 'column' : 'row'} edges or unequal gaps (edge drift ${Math.round(Math.max(...drift))}px; gaps ${current.gaps.map(Math.round).join('/')}px versus ${reference.gaps.map(Math.round).join('/')}px${rowDrift ? `; cross-axis gap ${Math.round(current.crossGap!)}px versus ${Math.round(reference.crossGap!)}px` : ''}). ${review ? 'Name the related groups with data-ds-fault-lines to enforce alignment, or mark an intentional exception off.' : 'Use the same tracks and gap for this family at this viewport.'}`});
74
+ }
75
+ return findings;
76
+ }
77
+
78
+
79
+ /** The same rendered geometry as the checker, painted without affecting layout or hit testing. */
80
+ export function paintFaultLines(root: ParentNode, layer: SVGSVGElement): void {
81
+ const doc = layer.ownerDocument;
82
+ const width = doc.documentElement.clientWidth;
83
+ const height = doc.defaultView!.innerHeight;
84
+ layer.setAttribute('viewBox', `0 0 ${width} ${height}`);
85
+ layer.setAttribute('width', String(width));
86
+ layer.setAttribute('height', String(height));
87
+ layer.setAttribute('aria-hidden', 'true');
88
+ layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;overflow:hidden;z-index:2147483644';
89
+ layer.replaceChildren();
90
+ const grids = faultLineGrids(root);
91
+ const failed = new Set(inspectFaultLines(root).map(finding => finding.element));
92
+ const drawn = new Set<string>();
93
+ const referenceInk = '#087ba8';
94
+ const faultInk = '#de0331';
95
+ function mark(tag: string, attributes: Record<string, string | number>, title?: string) {
96
+ const node = doc.createElementNS('http://www.w3.org/2000/svg', tag);
97
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
98
+ if (title) {const tip = doc.createElementNS(node.namespaceURI, 'title');tip.textContent = title;node.appendChild(tip);}
99
+ layer.appendChild(node);
100
+ return node;
101
+ }
102
+ for (let index = 0; index < grids.length; index++) {
103
+ const grid = grids[index]!;
104
+ const reference = referenceFor(grids, index) ?? grid;
105
+ for (const edge of reference.lines) {
106
+ const key = `${grid.axis}:${edge}`;
107
+ if (drawn.has(key)) continue;
108
+ drawn.add(key);
109
+ mark('line', { 'data-fault-reference': '', x1: grid.axis === 'x' ? edge : 0, y1: grid.axis === 'y' ? edge : 0,
110
+ x2: grid.axis === 'x' ? edge : width, y2: grid.axis === 'y' ? edge : height,
111
+ stroke: referenceInk, 'stroke-width': 1, 'stroke-dasharray': '4 4' }, 'Reference edge');
112
+ }
113
+ const broken = failed.has(grid.element);
114
+ if (!broken) continue;
115
+ // Draw each actual child so drift inside a later row is visible too.
116
+ for (const child of Array.from(grid.element.children).filter(visible)) {
117
+ const box = child.getBoundingClientRect();
118
+ const edges = grid.axis === 'x' ? [box.left, box.right] : [box.top, box.bottom];
119
+ for (const edge of edges) {
120
+ if (reference.lines.some(expected => near(edge, expected))) continue;
121
+ mark('line', { 'data-fault-actual': '', x1: grid.axis === 'x' ? edge : box.left, y1: grid.axis === 'y' ? edge : box.top,
122
+ x2: grid.axis === 'x' ? edge : box.right, y2: grid.axis === 'y' ? edge : box.bottom,
123
+ stroke: faultInk, 'stroke-width': 2 }, 'Actual edge differs from the dashed reference');
124
+ }
125
+ }
126
+ // Row spacing can drift even when every column edge still aligns.
127
+ const rows: DOMRect[][] = [];
128
+ for (const child of Array.from(grid.element.children).filter(visible)) {
129
+ const box = child.getBoundingClientRect();
130
+ const start = grid.axis === 'x' ? box.top : box.left;
131
+ const row = rows.find(row => near(grid.axis === 'x' ? row[0]!.top : row[0]!.left, start));
132
+ if (row) row.push(box); else rows.push([box]);
133
+ }
134
+ rows.sort((a,b) => grid.axis === 'x' ? a[0]!.top-b[0]!.top : a[0]!.left-b[0]!.left);
135
+ const bounds = grid.element.getBoundingClientRect();
136
+ const crossGap = reference.crossGap ?? grid.crossGap;
137
+ if (crossGap !== undefined) for (let row = 1; row < rows.length; row++) {
138
+ const previousEnd = Math.max(...rows[row-1]!.map(box => grid.axis === 'x' ? box.bottom : box.right));
139
+ const actual = Math.min(...rows[row]!.map(box => grid.axis === 'x' ? box.top : box.left));
140
+ const expected = previousEnd+crossGap;
141
+ if (near(actual,expected)) continue;
142
+ for (const [edge,kind] of [[expected,'reference'],[actual,'actual']] as const) {
143
+ mark('line', {[`data-fault-${kind}`]: '', x1:grid.axis === 'x' ? bounds.left : edge, y1:grid.axis === 'x' ? edge : bounds.top,
144
+ x2:grid.axis === 'x' ? bounds.right : edge, y2:grid.axis === 'x' ? edge : bounds.bottom,
145
+ stroke:kind === 'reference' ? referenceInk : faultInk, 'stroke-width':kind === 'reference' ? 1 : 2,
146
+ 'stroke-dasharray':kind === 'reference' ? '4 4' : ''}, `Row gap ${Math.round(actual-previousEnd)}px; reference ${Math.round(crossGap)}px`);
147
+ }
148
+ }
149
+ const box = grid.element.getBoundingClientRect();
150
+ const label = mark('text', { 'data-fault-gap': '', x: Math.max(4,box.left), y: Math.max(16,Math.min(height-4,box.top+16)),
151
+ fill: faultInk, stroke: 'var(--ds-background-color, white)', 'stroke-width': 3, 'paint-order': 'stroke',
152
+ 'font-family': 'var(--ds-typeface-regular, sans-serif)', 'font-size': 12 });
153
+ const gaps = grid.gaps.map(gap => Math.round(gap)).join('/');
154
+ const expected = reference.gaps.map(gap => Math.round(gap)).join('/');
155
+ label.textContent = `Gap ${gaps || '—'}px · reference ${expected || '—'}px` +
156
+ (grid.crossGap !== undefined && reference.crossGap !== undefined && !near(grid.crossGap,reference.crossGap)
157
+ ? ` · row gap ${Math.round(grid.crossGap)}px / ${Math.round(reference.crossGap)}px` : '');
158
+ }
159
+ }
@@ -1,4 +1,4 @@
1
- /* version 0.3.0 */
1
+ /* version 0.3.1 */
2
2
 
3
3
  /* Noto Sans for Japanese and Chinese, for a product that shows them. Opt in:
4
4
  link it beside one.css or fonts.css, or ask Sync 1 for "cjk": true. Without
@@ -1,4 +1,4 @@
1
- /* version 0.3.0 */
1
+ /* version 0.3.1 */
2
2
 
3
3
  /* Design system 1's faces and the typeface tokens that name them, on their
4
4
  own: a page that does not load one.css links this sheet (one.css imports
@@ -1,4 +1,4 @@
1
- /* version 0.3.0 */
1
+ /* version 0.3.1 */
2
2
 
3
3
  @import "./grid.css";
4
4
  @import "./component-tokens.css";
@@ -246,7 +246,8 @@ body {
246
246
  }
247
247
 
248
248
  .ds-input::placeholder {
249
- color: var(--ds-input-placeholder-color, var(--ds-text-color-muted));
249
+ /* one.css is copied on its own, without component-tokens.css: the fallback is that file's muted ink. */
250
+ color: var(--ds-input-placeholder-color, var(--ds-text-color-muted, light-dark(#5f5f5f, #cacaca)));
250
251
  }
251
252
 
252
253
  .ds-card {
@@ -1,6 +1,7 @@
1
1
  import { LitElement, html, nothing, unsafeCSS, type PropertyValues } from "lit";
2
2
  import { styleMap } from "lit/directives/style-map.js";
3
3
  import { keyed } from "lit/directives/keyed.js";
4
+ import { contrastRatio, colorReader, composite, type RGBA } from "../0-face/text-contrast";
4
5
  import { define } from "../0-face/define";
5
6
  import { provideIcons } from "./ds-icon";
6
7
  import { avatarIcon } from "./generated/icon-avatar";
@@ -12,9 +13,11 @@ provideIcons({ avatar: avatarIcon });
12
13
 
13
14
  /** The ink that reads on a fill: black on a light one, white on a dark one. */
14
15
  const inkOn = (rgb: number[]) => {
15
- const linear = rgb.map((n) => (n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4));
16
- const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
17
- return luminance > 0.22 ? "var(--ds-black)" : "var(--ds-white)";
16
+ const fill = [...rgb, 1] as RGBA;
17
+ const white: RGBA = [1, 1, 1, 1], black: RGBA = [42 / 255, 42 / 255, 42 / 255, 1];
18
+ if (contrastRatio(black, fill) >= 4.5) return "var(--ds-black)";
19
+ if (contrastRatio(white, fill) >= 4.5) return "var(--ds-white)";
20
+ return "black";
18
21
  };
19
22
 
20
23
  /** Saved identity colours are hex; their ink must stay legible in either theme. */
@@ -93,6 +96,28 @@ export class Avatar extends LitElement {
93
96
  this.fit = "cover";
94
97
  this.logo = this.mask = this.outlined = this.pending = this.plain = this.loaded = this.failed = false;
95
98
  }
99
+ private inkObserver?: MutationObserver;
100
+ private inkFrame = 0;
101
+ private readonly queueInk = () => {
102
+ const view = this.ownerDocument.defaultView!;
103
+ if (!this.inkFrame) this.inkFrame = view.requestAnimationFrame(() => { this.inkFrame = 0; this.resolveInk(); });
104
+ };
105
+ connectedCallback() {
106
+ super.connectedCallback();
107
+ const view = this.ownerDocument.defaultView!;
108
+ this.inkObserver = new view.MutationObserver(this.queueInk);
109
+ // Observe only the painted ancestors, never our own shadow's ink writes.
110
+ for (let parent: Element | null = this; parent; parent = parent.parentElement ?? (parent.getRootNode() as ShadowRoot).host ?? null) {
111
+ this.inkObserver.observe(parent, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
112
+ }
113
+ view.addEventListener('load', this.queueInk, true);
114
+ }
115
+ disconnectedCallback() {
116
+ super.disconnectedCallback(); this.inkObserver?.disconnect();
117
+ const view = this.ownerDocument.defaultView!;
118
+ view.cancelAnimationFrame(this.inkFrame); this.inkFrame = 0;
119
+ view.removeEventListener('load', this.queueInk, true);
120
+ }
96
121
  protected willUpdate(changed: PropertyValues<this>) {
97
122
  if (changed.has("src")) {
98
123
  this.loaded = false;
@@ -102,7 +127,7 @@ export class Avatar extends LitElement {
102
127
  }
103
128
  /** A fill named by a variable (`var(--brand-highlight)`) gets its ink once the browser has resolved it. */
104
129
  protected updated() {
105
- if (this.ink || this.pending || this.plain || !this.color || avatarInk(this.color)) return;
130
+ if (this.ink || this.pending || this.plain) return;
106
131
  this.resolveInk();
107
132
  }
108
133
  /** Reads the fill as drawn; while the variable is still undefined (its stylesheet on its way), looks again next
@@ -110,9 +135,23 @@ export class Avatar extends LitElement {
110
135
  private resolveInk(tries = 0) {
111
136
  const frame = this.renderRoot.querySelector<HTMLElement>(".frame");
112
137
  if (!frame || !this.isConnected) return;
113
- const ink = inkOnResolved(getComputedStyle(frame).backgroundColor);
114
- if (ink) { frame.style.color = ink; return; }
115
- if (tries < 60) requestAnimationFrame(() => this.resolveInk(tries + 1));
138
+ if (this.ink || this.pending || this.plain) return;
139
+ const view = this.ownerDocument.defaultView!;
140
+ const read = colorReader(this.ownerDocument);
141
+ let fill = read(view.getComputedStyle(frame).backgroundColor);
142
+ if (fill && fill[3] > 0) {
143
+ for (let parent: Element | null = this; parent && fill[3] < 1; parent = parent.parentElement ?? (parent.getRootNode() as ShadowRoot).host ?? null) {
144
+ const background = read(view.getComputedStyle(parent).backgroundColor);
145
+ if (background) fill = composite(fill, background);
146
+ }
147
+ if (fill[3] < 1) return;
148
+ const tokens = view.getComputedStyle(this);
149
+ const black = read(tokens.getPropertyValue('--ds-black').trim()), white = read(tokens.getPropertyValue('--ds-white').trim());
150
+ const ink = black && contrastRatio(black, fill) >= 4.5 ? 'var(--ds-black)' : white && contrastRatio(white, fill) >= 4.5 ? 'var(--ds-white)' : contrastRatio([0,0,0,1], fill) >= 4.5 ? 'black' : 'white';
151
+ if (frame.style.color !== ink) frame.style.color = ink;
152
+ return;
153
+ }
154
+ if (tries < 60) this.ownerDocument.defaultView!.requestAnimationFrame(() => this.resolveInk(tries + 1));
116
155
  if (tries === 0 && this.ownerDocument.readyState !== "complete") {
117
156
  this.ownerDocument.defaultView?.addEventListener("load", () => this.resolveInk(60), { once: true });
118
157
  }
@@ -129,7 +168,7 @@ export class Avatar extends LitElement {
129
168
  >`;
130
169
  return html`<span
131
170
  class="frame"
132
- style=${styleMap({ backgroundColor: !this.pending && !this.plain ? this.color || undefined : undefined, color: !this.pending ? this.ink || avatarInk(this.color) : undefined })}
171
+ style=${styleMap({ backgroundColor: !this.pending && !this.plain ? this.color || undefined : undefined, color: !this.pending ? this.ink || (!this.plain ? avatarInk(this.color) : undefined) : undefined })}
133
172
  role=${this.label ? "img" : nothing}
134
173
  aria-label=${this.label || nothing}
135
174
  aria-hidden=${this.label ? nothing : "true"}
@@ -39,7 +39,8 @@
39
39
  }
40
40
 
41
41
  :host([fit][control="slotted"]) ::slotted(:is(button, a)) {
42
- width: max-content !important;
42
+ /* The host hugs on the grid; its visible face must occupy that same box. */
43
+ width: 100% !important;
43
44
  min-width: var(--ds-button-min-width, 0px) !important;
44
45
  justify-content: var(--ds-button-align, flex-start) !important;
45
46
  }
@@ -46,7 +46,8 @@
46
46
  }
47
47
  :host([fit]) :is(button, a) {
48
48
  flex: 0 0 auto;
49
- width: max-content;
49
+ /* Hug sizing belongs to the host, including its grid rounding. */
50
+ width: 100%;
50
51
  min-width: var(--ds-button-min-width, 0px);
51
52
  justify-content: var(--ds-button-align, flex-start);
52
53
  }
@@ -29,7 +29,8 @@
29
29
  padding-inline: var(--_tag-padding, calc(4px * var(--ds-sf, 1)));
30
30
  border-radius: var(--_tag-radius, var(--ds-radius));
31
31
  background: var(--ds-tag-background, light-dark(color-mix(in srgb, var(--ds-tag-accent, var(--ds-grey, #7a7a7a)) 28%, white), color-mix(in srgb, var(--ds-tag-accent, var(--ds-grey, #7a7a7a)) 20%, var(--ds-black, #2a2a2a))));
32
- color: var(--ds-tag-color, light-dark(color-mix(in srgb, var(--ds-tag-accent, var(--ds-grey, #7a7a7a)) 45%, black), var(--ds-tag-accent, var(--ds-grey, #7a7a7a))));
32
+ /* Tint the dark-mode ink: raw grey/accent colours cannot meet 4.5:1 on this fill. */
33
+ color: var(--ds-tag-color, light-dark(color-mix(in srgb, var(--ds-tag-accent, var(--ds-grey, #7a7a7a)) 45%, black), color-mix(in srgb, var(--ds-tag-accent, var(--ds-grey, #7a7a7a)) 30%, white)));
33
34
  font-family: var(--ds-typeface-regular);
34
35
  font-feature-settings: var(--ds-font-features);
35
36
  font-size: var(--_tag-font-size, var(--ds-type-size-default));
@@ -29,6 +29,7 @@ export function bindContextMenuTrigger(
29
29
  let suppressed: HTMLElement | null = null;
30
30
  let release: ReturnType<typeof setTimeout> | undefined;
31
31
  let disposed = false;
32
+ let mousePress: { event: PointerEvent; path: EventTarget[]; target: EventTarget | null } | undefined;
32
33
  if (aria) trigger.setAttribute("aria-haspopup", "menu");
33
34
  if (selection) trigger.classList.add("ds-context-trigger");
34
35
  const enabled = (control: HTMLElement) => control.isConnected &&
@@ -48,6 +49,7 @@ export function bindContextMenuTrigger(
48
49
  release = setTimeout(clearSuppression, 1000);
49
50
  };
50
51
  const cancel = () => {
52
+ mousePress = undefined;
51
53
  clearTimeout(timer); timer = undefined; point = undefined;
52
54
  doc.removeEventListener("pointermove", move);
53
55
  doc.removeEventListener("pointerup", cancel);
@@ -56,10 +58,28 @@ export function bindContextMenuTrigger(
56
58
  };
57
59
  const context = (event: MouseEvent) => {
58
60
  if (event.defaultPrevented) return;
59
- const control = resolve(event); if (!control || !enabled(control)) return;
61
+ // Settling a transform on pointerdown can move another row beneath the
62
+ // pointer before Chromium hit-tests contextmenu. Keep the initiating row.
63
+ const pressed = mousePress;
64
+ mousePress = undefined;
65
+ const followsPress = pressed && event.button === 2 && Math.hypot(event.clientX - pressed.event.clientX, event.clientY - pressed.event.clientY) <= 10;
66
+ // Native events lose their composed path after dispatch. Delegated and
67
+ // shadow-root controls still need the original path and unretargeted target.
68
+ const gesture = followsPress ? new Proxy(pressed.event, {
69
+ get(target, key) {
70
+ if (key === "composedPath") return () => pressed.path;
71
+ if (key === "target") return pressed.target;
72
+ const value = Reflect.get(target, key, target);
73
+ return typeof value === "function" ? value.bind(target) : value;
74
+ },
75
+ }) : event;
76
+ // Adopted controls retain their creating realm’s prototype; ownerDocument
77
+ // alone cannot choose an instanceof constructor. Containment stays authoritative.
78
+ if (gesture !== event && (typeof (gesture.target as Node | null)?.nodeType !== "number" || !trigger.contains(gesture.target as Node))) return;
79
+ const control = resolve(gesture); if (!control || !enabled(control)) return;
60
80
  cancel();
61
81
  if (suppressed === control) { event.preventDefault(); return; }
62
- if (open({ x: event.clientX, y: event.clientY }, control, event) === false) return;
82
+ if (open({ x: event.clientX, y: event.clientY }, control, gesture) === false) return;
63
83
  event.preventDefault();
64
84
  if (selection && event.button === 0) arm(control);
65
85
  };
@@ -88,6 +108,11 @@ export function bindContextMenuTrigger(
88
108
  if (open(at, control, event) !== false) arm(control);
89
109
  }, LONG_PRESS);
90
110
  };
111
+ const rememberMouse = (event: PointerEvent) => {
112
+ mousePress = !event.defaultPrevented && event.pointerType === "mouse" && event.button === 2 ? { event, path: event.composedPath(), target: event.target } : undefined;
113
+ };
114
+ trigger.addEventListener("pointerdown", rememberMouse, true);
115
+ trigger.addEventListener("pointercancel", cancel, true);
91
116
  trigger.addEventListener("contextmenu", context);
92
117
  trigger.addEventListener("keydown", key);
93
118
  trigger.addEventListener("pointerdown", press);
@@ -98,6 +123,8 @@ export function bindContextMenuTrigger(
98
123
  trigger.removeEventListener("contextmenu", context);
99
124
  trigger.removeEventListener("keydown", key);
100
125
  trigger.removeEventListener("pointerdown", press);
126
+ trigger.removeEventListener("pointerdown", rememberMouse, true);
127
+ trigger.removeEventListener("pointercancel", cancel, true);
101
128
  if (aria) {
102
129
  if (previousAria === null) trigger.removeAttribute("aria-haspopup"); else trigger.setAttribute("aria-haspopup", previousAria);
103
130
  }
@@ -1,3 +1,4 @@
1
+ import { define } from "../0-face/define";
1
2
  // ds-form.ts
2
3
  // Form component for handling form submissions and validation
3
4
 
@@ -338,7 +339,7 @@ export class Form extends LitElement {
338
339
  }
339
340
  }
340
341
 
341
- customElements.define("ds-form", Form);
342
+ define("ds-form", Form);
342
343
 
343
344
  declare global {
344
345
  interface HTMLElementTagNameMap {
@@ -1,3 +1,4 @@
1
+ import { define } from "../0-face/define";
1
2
  // ds-table.ts
2
3
  // A data table: rows one square high on hairlines, drawn from data with the
3
4
  // same rules as a native `<table class="ds-table">` (1-root/table.css). With
@@ -498,7 +499,7 @@ function text(value: unknown): string {
498
499
  return typeof value === "number" ? value.toLocaleString() : String(value);
499
500
  }
500
501
 
501
- customElements.define("ds-table", DsTable);
502
+ define("ds-table", DsTable);
502
503
 
503
504
  declare global {
504
505
  interface HTMLElementTagNameMap {
@@ -1,3 +1,4 @@
1
+ import { define } from "../0-face/define";
1
2
  // ds-container.ts
2
3
  // Container component with responsive width constraints
3
4
 
@@ -19,7 +20,7 @@ export class Container extends LitElement {
19
20
  }
20
21
  }
21
22
 
22
- customElements.define("ds-container", Container);
23
+ define("ds-container", Container);
23
24
 
24
25
  declare global {
25
26
  interface HTMLElementTagNameMap {
@@ -1 +1 @@
1
- {"version":3,"file":"constraint-panel.d.ts","sourceRoot":"","sources":["../../DS1/0-face/constraint-panel.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAKlF,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB;kBA2Rf,MAAM,GAAG,MAAM;;;;;;oBA9KI,UAAU;;EAuMlD;AAED,4HAA4H;AAC5H,eAAO,MAAM,gBAAgB,aAAa,CAAC;AAiB3C;;;;GAIG;AACH,eAAO,MAAM,WAAW,eACiD,CAAC;AAE1E,mHAAmH;AACnH,wBAAgB,gBAAgB,CAAC,MAAM,SAAyB,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,CAIxF;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CA4C7C"}
1
+ {"version":3,"file":"constraint-panel.d.ts","sourceRoot":"","sources":["../../DS1/0-face/constraint-panel.ts"],"names":[],"mappings":"AACA,OAAO,EAAsB,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAKlF,8EAA8E;AAC9E,eAAO,MAAM,kBAAkB,IAAI,CAAC;AAEpC;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB;kBAuSf,MAAM,GAAG,MAAM;;;;;;oBAjLI,UAAU;;EAkNlD;AAED,4HAA4H;AAC5H,eAAO,MAAM,gBAAgB,aAAa,CAAC;AAiB3C;;;;GAIG;AACH,eAAO,MAAM,WAAW,eACiD,CAAC;AAE1E,mHAAmH;AACnH,wBAAgB,gBAAgB,CAAC,MAAM,SAAyB,GAAG,MAAM,GAAG,MAAM,GAAG,IAAI,CAIxF;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CA4C7C"}