@krapwoo/ds-viewer 0.4.4 → 0.4.6
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.
- package/README.md +20 -10
- package/dist/cli/dev.js +31 -6
- package/dist/cli/endpoint.js +76 -1
- package/dist/cli/main.js +4 -2
- package/dist/cli/updateCheck.js +174 -43
- package/dist/cli/workspace.js +2 -1
- package/dist/native/catalog/catalogNavigation.js +11 -0
- package/dist/native/catalog/tokens.js +2 -0
- package/native/catalog/CatalogShell.tsx +47 -12
- package/native/catalog/CatalogSidebar.tsx +8 -7
- package/native/catalog/ComparisonGrid.tsx +11 -11
- package/native/catalog/ComparisonGroups.tsx +6 -8
- package/native/catalog/ComparisonList.tsx +6 -8
- package/native/catalog/SectionBlock.tsx +9 -6
- package/native/catalog/SpecimenSurface.tsx +64 -26
- package/native/catalog/UpdatePanel.tsx +161 -5
- package/native/catalog/catalogNavigation.ts +14 -1
- package/native/catalog/specimenFill.ts +102 -0
- package/native/catalog/specimenSurfaceStyle.ts +9 -3
- package/native/catalog/tokens.ts +2 -0
- package/native/catalog/types.ts +27 -9
- package/native/catalog/updatePanelState.ts +72 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic cell fill for `specimenSurface: 'auto'` (the default): decides whether an example
|
|
3
|
+
* blends into its white comparison cell, so that one cell can turn gray. Kept free of any runtime
|
|
4
|
+
* import so `node --test` can assert on it directly (see `__tests__/specimenFill.test.ts`).
|
|
5
|
+
*
|
|
6
|
+
* The rule looks only at the example's largest painted box (the element with a background that
|
|
7
|
+
* covers the most area): if that box is near-white (every channel close to 255, so pale tints
|
|
8
|
+
* don't count) and has no visible border, the example's edges
|
|
9
|
+
* vanish against the white cell. A shadow alone doesn't count as an edge. Small boxes (under a
|
|
10
|
+
* tenth of the cell) and text-only examples never trigger it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface Rgba { r: number; g: number; b: number; a: number }
|
|
14
|
+
|
|
15
|
+
/** One element inside an example, as measured in the browser. */
|
|
16
|
+
export interface PaintedBox {
|
|
17
|
+
area: number;
|
|
18
|
+
background: Rgba | null;
|
|
19
|
+
/** The top border's colour when its width is above 0, else null. */
|
|
20
|
+
border: Rgba | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A fill is near-white when every channel (after compositing over the white cell) is at least
|
|
24
|
+
* this. #fafafa and #f8f8f8 qualify; #f4f4f4 already reads as gray, and pale tints such as a
|
|
25
|
+
* positive banner's rgb(240, 255, 244) read as colour, even when they're as bright as white. */
|
|
26
|
+
const NEAR_WHITE_CHANNEL = 247;
|
|
27
|
+
/** At or above this, a border draws the edge itself (#e4e4e4 is 1.27:1). */
|
|
28
|
+
const VISIBLE_BORDER_CONTRAST = 1.2;
|
|
29
|
+
/** A box smaller than this share of the cell is a detail, not the example's body. */
|
|
30
|
+
const MIN_AREA_SHARE = 0.1;
|
|
31
|
+
|
|
32
|
+
/** Parses a computed CSS colour (`rgb(…)`/`rgba(…)`). Fully transparent or unknown values are null. */
|
|
33
|
+
export function parseCssColor(value: string): Rgba | null {
|
|
34
|
+
const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/.exec(value.trim());
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const alphaText = match[4];
|
|
37
|
+
const a = alphaText === undefined ? 1 : alphaText.endsWith('%') ? Number(alphaText.slice(0, -1)) / 100 : Number(alphaText);
|
|
38
|
+
if (!(a > 0)) return null;
|
|
39
|
+
return { r: Number(match[1]), g: Number(match[2]), b: Number(match[3]), a };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function channel(value: number): number {
|
|
43
|
+
const s = value / 255;
|
|
44
|
+
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when every channel of `color`, composited over white, is at least `NEAR_WHITE_CHANNEL`. */
|
|
48
|
+
export function isNearWhite(color: Rgba): boolean {
|
|
49
|
+
const over = (c: number) => color.a * c + (1 - color.a) * 255;
|
|
50
|
+
return Math.min(over(color.r), over(color.g), over(color.b)) >= NEAR_WHITE_CHANNEL;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** WCAG contrast ratio against white, after compositing a translucent colour over the white cell. */
|
|
54
|
+
export function contrastWithWhite(color: Rgba): number {
|
|
55
|
+
const over = (c: number) => color.a * c + (1 - color.a) * 255;
|
|
56
|
+
const luminance = 0.2126 * channel(over(color.r)) + 0.7152 * channel(over(color.g)) + 0.0722 * channel(over(color.b));
|
|
57
|
+
return 1.05 / (luminance + 0.05);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when the example's body is near-white with no visible border, so it vanishes on a white cell. */
|
|
61
|
+
export function blendsIntoCell(boxes: readonly PaintedBox[], cellArea: number): boolean {
|
|
62
|
+
let largest: PaintedBox | undefined;
|
|
63
|
+
for (const box of boxes) {
|
|
64
|
+
if (!box.background || box.area < cellArea * MIN_AREA_SHARE) continue;
|
|
65
|
+
if (!largest || box.area > largest.area) largest = box;
|
|
66
|
+
}
|
|
67
|
+
if (!largest?.background) return false;
|
|
68
|
+
if (!isNearWhite(largest.background)) return false;
|
|
69
|
+
return !(largest.border && contrastWithWhite(largest.border) >= VISIBLE_BORDER_CONTRAST);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Minimal DOM shapes, so this file needs no DOM lib types. */
|
|
73
|
+
interface MeasurableElement {
|
|
74
|
+
getBoundingClientRect(): { width: number; height: number };
|
|
75
|
+
querySelectorAll(selector: string): ArrayLike<MeasurableElement>;
|
|
76
|
+
}
|
|
77
|
+
type ComputedStyleReader = (element: MeasurableElement) => {
|
|
78
|
+
backgroundColor: string;
|
|
79
|
+
borderTopColor: string;
|
|
80
|
+
borderTopWidth: string;
|
|
81
|
+
visibility: string;
|
|
82
|
+
opacity: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Measures every element inside `cell` (up to `limit`) in the browser. */
|
|
86
|
+
export function measurePaintedBoxes(cell: MeasurableElement, readStyle: ComputedStyleReader, limit = 300): { boxes: PaintedBox[]; cellArea: number } {
|
|
87
|
+
const cellRect = cell.getBoundingClientRect();
|
|
88
|
+
const elements = cell.querySelectorAll('*');
|
|
89
|
+
const boxes: PaintedBox[] = [];
|
|
90
|
+
for (let i = 0; i < Math.min(elements.length, limit); i++) {
|
|
91
|
+
const element = elements[i];
|
|
92
|
+
const style = readStyle(element);
|
|
93
|
+
if (style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
|
94
|
+
const rect = element.getBoundingClientRect();
|
|
95
|
+
boxes.push({
|
|
96
|
+
area: rect.width * rect.height,
|
|
97
|
+
background: parseCssColor(style.backgroundColor),
|
|
98
|
+
border: parseFloat(style.borderTopWidth) > 0 ? parseCssColor(style.borderTopColor) : null,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return { boxes, cellArea: cellRect.width * cellRect.height };
|
|
102
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { SpecimenSurfaceKind } from './types.ts';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
* assert on it directly (see `
|
|
4
|
+
* The example wrapper's width policy inside a cell, kept free of any `react-native` import so
|
|
5
|
+
* `node --test` can assert on it directly (see `__tests__/specimenSurfaceStyle.test.ts`).
|
|
4
6
|
*
|
|
5
|
-
* The
|
|
7
|
+
* The wrapper always stretches to the full cross-axis width of whatever wraps it (a comparison
|
|
6
8
|
* cell, typically centered) rather than shrink-wrapping to its own content — `alignSelf: 'stretch'`
|
|
7
9
|
* overrides a centered parent's `alignItems` for this one child. A compact specimen (e.g. Button)
|
|
8
10
|
* still reads as centered, via `SpecimenSurface`'s own `alignItems`/`justifyContent: 'center'` on
|
|
@@ -13,3 +15,7 @@
|
|
|
13
15
|
export const SPECIMEN_SURFACE_WIDTH_STYLE = {
|
|
14
16
|
alignSelf: 'stretch',
|
|
15
17
|
} as const;
|
|
18
|
+
|
|
19
|
+
/** Each example's cell turns gray only when its example would vanish against white (see
|
|
20
|
+
* `specimenFill.ts`). A page can force a fill with `specimenSurface`. */
|
|
21
|
+
export const DEFAULT_SPECIMEN_SURFACE: SpecimenSurfaceKind = 'auto';
|
package/native/catalog/tokens.ts
CHANGED
|
@@ -119,6 +119,8 @@ export const CATALOG_COLOR = {
|
|
|
119
119
|
surfaceMuted: '#fafafa',
|
|
120
120
|
/** Hover and pressed feedback on nav rows and buttons. */
|
|
121
121
|
surfacePressed: '#f1f3f8',
|
|
122
|
+
/** An example's cell when the example itself is white (`specimenSurface`), darker than surfaceMuted so a white card's edges show. */
|
|
123
|
+
specimenStage: '#f2f2f2',
|
|
122
124
|
pageBackground: '#f6f6f4',
|
|
123
125
|
chip: '#eeeeee',
|
|
124
126
|
/** Active nav label, prop types, scale bars. */
|
package/native/catalog/types.ts
CHANGED
|
@@ -65,14 +65,12 @@ export type SpecimenSize = 'compact' | 'regular' | 'wide';
|
|
|
65
65
|
* content that is not a phone component (catalog chrome, token galleries). */
|
|
66
66
|
export type PreviewWidths = readonly number[] | 'full';
|
|
67
67
|
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
* their current full-width, unstaged presentation regardless of this setting. */
|
|
75
|
-
export type SpecimenSurfaceKind = 'neutral' | 'white' | 'dark' | 'transparent';
|
|
68
|
+
/** Fill of each example's cell. `'auto'` (default): a cell turns gray (#f2f2f2) only when its own
|
|
69
|
+
* example would vanish against white — a near-white body with no visible border, like a white
|
|
70
|
+
* card, sheet or neutral banner — so neighbouring examples keep a white cell. `'neutral'` makes
|
|
71
|
+
* every example cell gray, `'white'` and `'transparent'` keep them white, `'dark'` checks light
|
|
72
|
+
* components on a dark fill. Never applied to token galleries or sections. */
|
|
73
|
+
export type SpecimenSurfaceKind = 'auto' | 'neutral' | 'white' | 'dark' | 'transparent';
|
|
76
74
|
|
|
77
75
|
/** One real component this page's component is built from or composed with — rendered as a
|
|
78
76
|
* "Composition" subsection in Quick reference. `relationship` is `'built-in'` for a component
|
|
@@ -218,7 +216,7 @@ export interface SectionDef<TId extends string = string> {
|
|
|
218
216
|
* and everything else is 'regular'. */
|
|
219
217
|
specimenSize?: SpecimenSize;
|
|
220
218
|
/** Background stage behind every live component specimen on this page (list/grouped/grid cells,
|
|
221
|
-
* and a `render()` preview that isn't a token gallery). Defaults to `'
|
|
219
|
+
* and a `render()` preview that isn't a token gallery). Defaults to `'auto'`. See
|
|
222
220
|
* `SpecimenSurfaceKind`. */
|
|
223
221
|
specimenSurface?: SpecimenSurfaceKind;
|
|
224
222
|
/** Widths for this page's `render()` preview, e.g. `[402, 320]` to add a small-phone example.
|
|
@@ -258,6 +256,26 @@ export interface UpdateNotice {
|
|
|
258
256
|
releasedAt?: string;
|
|
259
257
|
}
|
|
260
258
|
|
|
259
|
+
/** Where the "check automatically" setting in effect comes from, strongest first: the
|
|
260
|
+
* `DS_VIEWER_NO_UPDATE_CHECK=1` environment variable (CI), your own switch on the update page
|
|
261
|
+
* (saved per project on this computer), the project's `updateCheck` config, or the default (on). */
|
|
262
|
+
export type AutoCheckSource = 'env' | 'personal' | 'project' | 'default';
|
|
263
|
+
|
|
264
|
+
/** What the update page and the sidebar's version line show. Written by `dev` at startup
|
|
265
|
+
* (`.ds-viewer/version.json`) and kept current by the local endpoint (`GET /version`,
|
|
266
|
+
* `POST /version/check`, `POST /version/auto-check`). */
|
|
267
|
+
export interface VersionStatus {
|
|
268
|
+
/** The installed ds-viewer version. */
|
|
269
|
+
current: string;
|
|
270
|
+
autoCheck: { enabled: boolean; source: AutoCheckSource };
|
|
271
|
+
/** The last successful check on this computer (any project), when known. */
|
|
272
|
+
lastCheckedAt?: string;
|
|
273
|
+
/** This run's most recent check: answered, npm unreachable, or not run (checks off). */
|
|
274
|
+
lastOutcome: 'ok' | 'unreachable' | 'not-run';
|
|
275
|
+
/** A newer version, when one is known. */
|
|
276
|
+
update: UpdateNotice | null;
|
|
277
|
+
}
|
|
278
|
+
|
|
261
279
|
/** One titled card on a token page (`SectionDef.tokenSections`). */
|
|
262
280
|
export interface TokenSection {
|
|
263
281
|
/** The kind of token this card holds, e.g. "Semantic colours", "Palette", "Body". */
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AutoCheckSource, VersionStatus } from './types.ts';
|
|
2
|
+
|
|
1
3
|
/** The same shape as `cli/updatePlan.ts`'s `UpdatePlan` — duplicated, not imported: `native/catalog/`
|
|
2
4
|
* is the published package root and never imports from `cli/` (the reverse is normal: `cli/`
|
|
3
5
|
* already imports `native/catalog/` throughout). The endpoint's `POST /plan` response is this
|
|
@@ -72,3 +74,73 @@ export function daysAgoLabel(releasedAt: string, nowMs: number): string {
|
|
|
72
74
|
if (days === 1) return 'Released 1 day ago.';
|
|
73
75
|
return `Released ${days} days ago.`;
|
|
74
76
|
}
|
|
77
|
+
|
|
78
|
+
/** "Last checked" on the update page. */
|
|
79
|
+
export function checkedAgoLabel(at: string | undefined, nowMs: number): string {
|
|
80
|
+
if (!at) return 'Never';
|
|
81
|
+
const minutes = Math.floor((nowMs - Date.parse(at)) / 60_000);
|
|
82
|
+
if (minutes < 1) return 'Just now';
|
|
83
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
84
|
+
const hours = Math.floor(minutes / 60);
|
|
85
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
86
|
+
const days = Math.floor(hours / 24);
|
|
87
|
+
return `${days} day${days === 1 ? '' : 's'} ago`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface VersionOverview {
|
|
91
|
+
pill?: 'latest' | 'off';
|
|
92
|
+
line: string;
|
|
93
|
+
note?: { variant: 'ok' | 'err'; bold?: string; text: string };
|
|
94
|
+
/** **Check now**, or **Try again** after npm didn't answer. */
|
|
95
|
+
action: 'check' | 'retry';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The update page when no newer version is known: which of "latest", "checks off", "not checked
|
|
99
|
+
* yet" and "couldn't reach npm" applies. Only a check that actually answered may say "latest".
|
|
100
|
+
* `justChecked` is true right after **Check now** found nothing newer. */
|
|
101
|
+
export function versionOverview(status: VersionStatus, justChecked: boolean, nowMs: number): VersionOverview {
|
|
102
|
+
if (status.lastOutcome === 'unreachable') {
|
|
103
|
+
const last = status.lastCheckedAt ? ` The last successful check, ${checkedAgoLabel(status.lastCheckedAt, nowMs).toLowerCase()}, found no newer version.` : '';
|
|
104
|
+
return { line: `You’re on ${status.current}.`, action: 'retry', note: { variant: 'err', bold: 'Couldn’t reach npm.', text: ` You may be offline.${last}` } };
|
|
105
|
+
}
|
|
106
|
+
if (status.lastOutcome === 'not-run') {
|
|
107
|
+
if (!status.autoCheck.enabled) {
|
|
108
|
+
return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off, so this can’t tell whether a newer version exists.`, action: 'check' };
|
|
109
|
+
}
|
|
110
|
+
return { line: `You’re on ${status.current}. Not checked yet: automatic checks run when the viewer starts.`, action: 'check' };
|
|
111
|
+
}
|
|
112
|
+
// Turned off after a check answered (approved mockup state 7b); a fresh Check now still wins.
|
|
113
|
+
if (!status.autoCheck.enabled && !justChecked) {
|
|
114
|
+
return { pill: 'off', line: `You’re on ${status.current}. Automatic checks are off.`, action: 'check' };
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
pill: 'latest',
|
|
118
|
+
line: 'You’re on the latest version.',
|
|
119
|
+
action: 'check',
|
|
120
|
+
...(justChecked ? { note: { variant: 'ok' as const, text: 'Checked just now. No newer version.' } } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The switch's helper text: where the setting in effect comes from. Only CI's environment
|
|
125
|
+
* variable locks the switch. */
|
|
126
|
+
export function autoCheckHelp(autoCheck: { enabled: boolean; source: AutoCheckSource }): { text: string; locked: boolean } {
|
|
127
|
+
if (autoCheck.source === 'env') return { text: 'Off in this environment (DS_VIEWER_NO_UPDATE_CHECK=1).', locked: true };
|
|
128
|
+
if (autoCheck.source === 'personal') {
|
|
129
|
+
return { text: autoCheck.enabled ? 'Once a day, when the viewer starts. Saved for you on this computer.' : 'Turned off by you on this computer.', locked: false };
|
|
130
|
+
}
|
|
131
|
+
if (!autoCheck.enabled) {
|
|
132
|
+
return { text: 'Off for this project (updateCheck: false in ds-viewer.config.ts). Turning it on here applies to you only.', locked: false };
|
|
133
|
+
}
|
|
134
|
+
return { text: 'Once a day, when the viewer starts.', locked: false };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The update's size pill. Breaking updates are always "Major" (the CLI decides that); otherwise
|
|
138
|
+
* a change in the second number is "Minor" and anything smaller "Patch". */
|
|
139
|
+
export function bumpLabel(current: string, latest: string, breaking: boolean): 'Major' | 'Minor' | 'Patch' {
|
|
140
|
+
if (breaking) return 'Major';
|
|
141
|
+
const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1, 4).map(Number);
|
|
142
|
+
const a = parts(current);
|
|
143
|
+
const b = parts(latest);
|
|
144
|
+
if (!a || !b) return 'Minor';
|
|
145
|
+
return a[0] === b[0] && a[1] === b[1] ? 'Patch' : 'Minor';
|
|
146
|
+
}
|