@dorsk/tsumikit 0.81.0 → 0.82.0

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 (102) hide show
  1. package/README.md +180 -2
  2. package/dist/breakpoints.d.ts +15 -0
  3. package/dist/breakpoints.js +14 -0
  4. package/dist/components/atoms/Badge.svelte +1 -0
  5. package/dist/components/atoms/Button.svelte +16 -5
  6. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  7. package/dist/components/atoms/Input.svelte +1 -0
  8. package/dist/components/atoms/Slider.svelte +1 -1
  9. package/dist/components/atoms/Text.svelte +4 -4
  10. package/dist/components/atoms/Text.svelte.d.ts +1 -1
  11. package/dist/components/layouts/AppShell.svelte +14 -0
  12. package/dist/components/layouts/Backdrop.svelte +152 -0
  13. package/dist/components/layouts/Backdrop.svelte.d.ts +30 -0
  14. package/dist/components/layouts/NavSection.svelte +1 -1
  15. package/dist/components/molecules/AttachmentList.svelte +1 -1
  16. package/dist/components/molecules/CapBar.svelte +1 -1
  17. package/dist/components/molecules/Composer.svelte +1 -1
  18. package/dist/components/molecules/Dropzone.svelte +1 -1
  19. package/dist/components/molecules/EmptyState.svelte +13 -6
  20. package/dist/components/molecules/EmptyState.svelte.d.ts +3 -0
  21. package/dist/components/molecules/Field.svelte +1 -1
  22. package/dist/components/molecules/FilterInput.svelte +5 -3
  23. package/dist/components/molecules/Meter.svelte +1 -1
  24. package/dist/components/molecules/Metric.svelte +3 -1
  25. package/dist/components/molecules/Modal.svelte +4 -4
  26. package/dist/components/molecules/Pagination.svelte +26 -2
  27. package/dist/components/molecules/Pagination.svelte.d.ts +3 -1
  28. package/dist/components/molecules/Prose.svelte +1 -0
  29. package/dist/components/molecules/RadioGroup.svelte +2 -2
  30. package/dist/components/molecules/RichSelect.svelte.d.ts +2 -2
  31. package/dist/components/molecules/SectionHeader.svelte +1 -1
  32. package/dist/components/molecules/SegmentedControl.svelte +55 -22
  33. package/dist/components/molecules/SegmentedControl.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SortableList.svelte +1 -1
  35. package/dist/components/molecules/StatusBar.svelte +2 -2
  36. package/dist/components/molecules/Tabs.svelte +32 -0
  37. package/dist/components/molecules/Tabs.svelte.d.ts +4 -0
  38. package/dist/components/molecules/ThemePicker.svelte +3 -3
  39. package/dist/components/molecules/VolumeSlider.svelte.d.ts +1 -1
  40. package/dist/components/organisms/DataTable.svelte +2 -2
  41. package/dist/dev-audit.d.ts +19 -0
  42. package/dist/dev-audit.js +259 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/media.svelte.d.ts +8 -0
  46. package/dist/media.svelte.js +26 -0
  47. package/dist/pending.svelte.d.ts +16 -0
  48. package/dist/pending.svelte.js +75 -0
  49. package/dist/stores/toast.svelte.d.ts +22 -0
  50. package/dist/stores/toast.svelte.js +54 -2
  51. package/dist/styles/app.css +2 -1
  52. package/dist/styles/font.css +19 -0
  53. package/dist/styles/fonts/LICENSE-Inter.txt +92 -0
  54. package/dist/styles/fonts/inter.woff2 +0 -0
  55. package/dist/styles/themes.css +58 -58
  56. package/dist/styles/tokens.css +15 -9
  57. package/dist/url-state.svelte.d.ts +22 -0
  58. package/dist/url-state.svelte.js +64 -0
  59. package/lint/breakpoints.mjs +33 -0
  60. package/lint/cli.mjs +99 -0
  61. package/lint/context.mjs +242 -0
  62. package/lint/index.mjs +97 -0
  63. package/lint/rules/breakpoints.mjs +25 -0
  64. package/lint/rules/img-alt.mjs +13 -0
  65. package/lint/rules/motion.mjs +85 -0
  66. package/lint/rules/no-raw-color.mjs +59 -0
  67. package/lint/rules/no-raw-control.mjs +23 -0
  68. package/lint/rules/no-raw-font.mjs +64 -0
  69. package/lint/rules/one-primary.mjs +74 -0
  70. package/lint/rules/spacing-scale.mjs +66 -0
  71. package/package.json +12 -2
  72. package/principles/01-visibility-of-status.md +32 -0
  73. package/principles/02-one-primary-action.md +31 -0
  74. package/principles/03-emphasise-by-de-emphasising.md +31 -0
  75. package/principles/04-labels-last-resort.md +30 -0
  76. package/principles/05-tint-the-greys.md +30 -0
  77. package/principles/06-depth-from-shade.md +30 -0
  78. package/principles/07-one-accent.md +31 -0
  79. package/principles/08-spacing-scale.md +30 -0
  80. package/principles/09-group-with-space.md +31 -0
  81. package/principles/10-real-typeface-fixed-scale.md +33 -0
  82. package/principles/11-mono-only-for-code.md +30 -0
  83. package/principles/12-measure.md +29 -0
  84. package/principles/13-imagery-sets-the-mood.md +32 -0
  85. package/principles/14-instant-calm-feedback.md +32 -0
  86. package/principles/15-undo-over-confirm.md +30 -0
  87. package/principles/16-empty-states.md +31 -0
  88. package/principles/17-errors-in-plain-words.md +31 -0
  89. package/principles/18-match-the-real-world.md +31 -0
  90. package/principles/19-consistency-and-standards.md +32 -0
  91. package/principles/20-error-prevention.md +31 -0
  92. package/principles/21-recognition-over-recall.md +31 -0
  93. package/principles/22-flexibility-and-shortcuts.md +31 -0
  94. package/principles/23-url-is-state.md +31 -0
  95. package/principles/24-links-are-links.md +31 -0
  96. package/principles/25-visible-focus.md +32 -0
  97. package/principles/26-generous-targets.md +31 -0
  98. package/principles/27-cheap-motion-respect-preferences.md +32 -0
  99. package/principles/28-less-but-better.md +31 -0
  100. package/principles/29-colour-is-not-the-only-cue.md +31 -0
  101. package/principles/30-help-where-needed.md +31 -0
  102. package/principles/README.md +62 -0
@@ -0,0 +1,64 @@
1
+ import { inlineDeclarations, walkDeclarations, walkMarkup } from '../context.mjs';
2
+
3
+ /**
4
+ * @typedef {import('../context.mjs').Context} Context
5
+ * @typedef {import('../context.mjs').DeclarationLike} DeclarationLike
6
+ */
7
+
8
+ const TOKEN_ONLY = /^(?:var\(--font-[\w-]+\)|inherit|initial|unset|revert)\s*(?:,\s*(?:var\(--font-[\w-]+\)|[a-z-]+))*$/i;
9
+ const MONO = /var\(--font-mono\)/;
10
+ const CODE_WORDS = new Set([
11
+ 'code', 'pre', 'kbd', 'samp', 'tt', 'mono', 'monospace', 'term', 'terminal', 'console', 'shell',
12
+ 'cmd', 'command', 'prompt', 'log', 'logs', 'diff', 'json', 'yaml', 'toml', 'hash', 'sha', 'digest',
13
+ 'path', 'snippet', 'editor', 'trace', 'stack', 'output', 'stdout', 'stderr', 'hex', 'uuid', 'ansi'
14
+ ]);
15
+
16
+ /** @param {string[]} selectors */
17
+ export function isCodeIsh(selectors) {
18
+ return selectors.some((s) =>
19
+ s
20
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
21
+ .toLowerCase()
22
+ .split(/[^a-z0-9]+/)
23
+ .some((w) => CODE_WORDS.has(w))
24
+ );
25
+ }
26
+
27
+ /**
28
+ * @param {Context} ctx
29
+ * @param {DeclarationLike} declaration
30
+ * @param {string[]} selectors
31
+ */
32
+ function check(ctx, declaration, selectors) {
33
+ if (declaration.property !== 'font-family') return;
34
+ if (selectors.some((s) => /:root|:host|\[data-theme|\[data-look/.test(s))) return;
35
+ const value = declaration.value.trim();
36
+ if (!TOKEN_ONLY.test(value)) {
37
+ ctx.report(
38
+ 'no-raw-font',
39
+ declaration.start,
40
+ `raw font-family "${value}"; use var(--font-sans) / var(--font-mono) / var(--font-numeric) / var(--font-label)`
41
+ );
42
+ return;
43
+ }
44
+ if (MONO.test(value) && !isCodeIsh(selectors)) {
45
+ ctx.report(
46
+ 'no-raw-font',
47
+ declaration.start,
48
+ `--font-mono on a non-code element (${selectors.at(-1) ?? 'inline style'}); use var(--font-numeric) for values or var(--font-label) for labels, or add tsumikit-lint-ignore if it really is code`
49
+ );
50
+ }
51
+ }
52
+
53
+ export default /** @type {import('../context.mjs').LintRule} */ ({
54
+ id: 'no-raw-font',
55
+ description: 'font-family other than the --font-* tokens; --font-mono only on code-ish elements',
56
+ check(ctx) {
57
+ walkDeclarations(ctx, (d, { selectors }) => check(ctx, d, selectors));
58
+ walkMarkup(ctx, (node) => {
59
+ if (node.type !== 'RegularElement' && node.type !== 'Component') return;
60
+ const selectors = node.type === 'RegularElement' ? [node.name] : [];
61
+ for (const d of inlineDeclarations(node)) check(ctx, d, selectors);
62
+ });
63
+ }
64
+ });
@@ -0,0 +1,74 @@
1
+ import { staticAttribute } from '../context.mjs';
2
+
3
+ /**
4
+ * @typedef {import('../context.mjs').Fragment} Fragment
5
+ * @typedef {import('../context.mjs').MarkupNode} MarkupNode
6
+ * @typedef {import('../context.mjs').ElementLike} ElementLike
7
+ */
8
+
9
+ /**
10
+ * Returns the primaries that can render at the same time inside `node`.
11
+ * Exclusive branches (if/else, await states) contribute the largest branch;
12
+ * snippets are separate scopes checked on their own.
13
+ * @param {MarkupNode | Fragment | null | undefined} node
14
+ * @param {Fragment[]} scopes
15
+ * @returns {ElementLike[]}
16
+ */
17
+ function primaries(node, scopes) {
18
+ if (!node || typeof node !== 'object') return [];
19
+ switch (node.type) {
20
+ case 'Fragment':
21
+ return node.nodes.flatMap((n) => primaries(n, scopes));
22
+ case 'IfBlock':
23
+ return largest(primaries(node.consequent, scopes), primaries(node.alternate, scopes));
24
+ case 'AwaitBlock':
25
+ return largest(
26
+ primaries(node.pending, scopes),
27
+ primaries(node.then, scopes),
28
+ primaries(node.catch, scopes)
29
+ );
30
+ case 'EachBlock':
31
+ return largest(primaries(node.body, scopes), primaries(node.fallback, scopes));
32
+ case 'KeyBlock':
33
+ return primaries(node.fragment, scopes);
34
+ case 'SnippetBlock':
35
+ scopes.push(node.body);
36
+ return [];
37
+ case 'Component':
38
+ case 'RegularElement':
39
+ case 'SvelteElement':
40
+ case 'SvelteBoundary':
41
+ case 'SvelteComponent':
42
+ case 'SvelteSelf': {
43
+ const own =
44
+ node.type === 'Component' && staticAttribute(node, 'variant') === 'primary' ? [node] : [];
45
+ return [...own, ...primaries(node.fragment, scopes)];
46
+ }
47
+ default:
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /** @template T @param {T[][]} lists */
53
+ function largest(...lists) {
54
+ return lists.reduce((a, b) => (b.length > a.length ? b : a), /** @type {T[]} */ ([]));
55
+ }
56
+
57
+ export default /** @type {import('../context.mjs').LintRule} */ ({
58
+ id: 'one-primary',
59
+ description: 'more than one variant="primary" rendered in the same branch',
60
+ check(ctx) {
61
+ if (!ctx.fragment) return;
62
+ const scopes = [ctx.fragment];
63
+ while (scopes.length) {
64
+ const found = primaries(scopes.shift(), scopes);
65
+ for (const node of found.slice(1)) {
66
+ ctx.report(
67
+ 'one-primary',
68
+ node.start,
69
+ `${found.length} primary actions render together (first at line ${ctx.loc(found[0].start).line}); keep one primary per view`
70
+ );
71
+ }
72
+ }
73
+ }
74
+ });
@@ -0,0 +1,66 @@
1
+ import { inlineDeclarations, walkDeclarations, walkMarkup } from '../context.mjs';
2
+
3
+ /**
4
+ * @typedef {import('../context.mjs').Context} Context
5
+ * @typedef {import('../context.mjs').DeclarationLike} DeclarationLike
6
+ */
7
+
8
+ const PROPERTY = /^(padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left|scroll-padding|scroll-margin)(-|$)/;
9
+ const LENGTH = /(?<![\w.-])(-?\d*\.?\d+)(px|rem)(?![\w%])/g;
10
+
11
+ /** @type {ReadonlyArray<readonly [string, number]>} */
12
+ export const SPACING_SCALE = [
13
+ ['--sp-0-5', 0.125],
14
+ ['--sp-1', 0.25],
15
+ ['--sp-1-5', 0.375],
16
+ ['--sp-2', 0.5],
17
+ ['--sp-3', 0.75],
18
+ ['--sp-4', 1],
19
+ ['--sp-5', 1.25],
20
+ ['--sp-6', 1.5],
21
+ ['--sp-8', 2],
22
+ ['--sp-10', 2.5],
23
+ ['--sp-12', 3]
24
+ ];
25
+
26
+ /** @param {string} value */
27
+ function stripFunctions(value) {
28
+ return value.replace(/\b(var|env|url)\((?:[^)(]|\([^)(]*\))*\)/gi, ' ');
29
+ }
30
+
31
+ /** @param {number} amount @param {string} unit */
32
+ export function tokenFor(amount, unit) {
33
+ const rem = unit === 'px' ? amount / 16 : amount;
34
+ const match = SPACING_SCALE.find(([, v]) => Math.abs(v - Math.abs(rem)) < 1e-6);
35
+ if (!match) return null;
36
+ return rem < 0 ? `calc(-1 * var(${match[0]}))` : `var(${match[0]})`;
37
+ }
38
+
39
+ /** @param {Context} ctx @param {DeclarationLike} declaration */
40
+ function check(ctx, declaration) {
41
+ if (!PROPERTY.test(declaration.property)) return;
42
+ for (const m of stripFunctions(declaration.value).matchAll(LENGTH)) {
43
+ const amount = Number(m[1]);
44
+ const unit = m[2];
45
+ if (amount === 0 || (unit === 'px' && Math.abs(amount) === 1)) continue;
46
+ const token = tokenFor(amount, unit);
47
+ const hint = token ? `use ${token}` : 'not on the --sp-* scale';
48
+ ctx.report(
49
+ 'spacing-scale',
50
+ declaration.start,
51
+ `raw ${m[0]} in "${declaration.property}"; ${hint}`
52
+ );
53
+ }
54
+ }
55
+
56
+ export default /** @type {import('../context.mjs').LintRule} */ ({
57
+ id: 'spacing-scale',
58
+ description: 'px/rem in padding/margin/gap/inset that is not a --sp-* token (1px allowed)',
59
+ check(ctx) {
60
+ walkDeclarations(ctx, (d) => check(ctx, d));
61
+ walkMarkup(ctx, (node) => {
62
+ if (node.type !== 'RegularElement' && node.type !== 'Component') return;
63
+ for (const d of inlineDeclarations(node)) check(ctx, d);
64
+ });
65
+ }
66
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,8 +24,13 @@
24
24
  "**/*.css"
25
25
  ],
26
26
  "files": [
27
- "dist"
27
+ "dist",
28
+ "lint",
29
+ "principles"
28
30
  ],
31
+ "bin": {
32
+ "tsumikit-lint": "./lint/cli.mjs"
33
+ },
29
34
  "svelte": "./dist/index.js",
30
35
  "types": "./dist/index.d.ts",
31
36
  "exports": {
@@ -34,6 +39,10 @@
34
39
  "svelte": "./dist/index.js",
35
40
  "default": "./dist/index.js"
36
41
  },
42
+ "./dev-audit": {
43
+ "types": "./dist/dev-audit.d.ts",
44
+ "default": "./dist/dev-audit.js"
45
+ },
37
46
  "./styles/app.css": "./dist/styles/app.css",
38
47
  "./styles/variables.css": "./dist/styles/variables.css",
39
48
  "./styles/tokens.css": "./dist/styles/tokens.css",
@@ -41,6 +50,7 @@
41
50
  "./styles/reset.css": "./dist/styles/reset.css",
42
51
  "./styles/utilities.css": "./dist/styles/utilities.css",
43
52
  "./styles/syntax.css": "./dist/styles/syntax.css",
53
+ "./styles/font.css": "./dist/styles/font.css",
44
54
  "./looks/*.css": "./dist/looks/*.css"
45
55
  },
46
56
  "publishConfig": {
@@ -0,0 +1,32 @@
1
+ # Visibility of system status
2
+
3
+ Every action gets an acknowledgement within 100 ms, and the outcome is stated, not implied.
4
+
5
+ ## Why
6
+
7
+ Nielsen heuristic 1: users forgive slowness but not silence; a button that goes quiet after a click gets clicked again or abandoned. The Doherty threshold adds the budget: below about 400 ms people stay in flow, above it they need visible progress.
8
+ Sources: https://www.nngroup.com/articles/ten-usability-heuristics/ , https://lawsofux.com/doherty-threshold/
9
+
10
+ ## Do
11
+
12
+ - Change the control the moment it is pressed: keep its label, add a busy indicator next to it.
13
+ - State the result in words ("Saved. 3 fields updated."), and offer the way back when there is one.
14
+ - Show progress for anything that can take more than a second; show how much is done if you know.
15
+ - Mark long-running background work somewhere persistent (a status bar, a queue count).
16
+
17
+ ## Don't
18
+
19
+ - Disable the button and show nothing else.
20
+ - Swap the label for a spinner, so the user forgets what they pressed.
21
+ - Report success only by closing the dialog.
22
+
23
+ ## In tsumikit
24
+
25
+ - `Button` with `loading` (true while pending, `'done'` for the confirmation tick) and the `pending()` helper with `loadingDelay` / `loadingMin` for flicker-free timing.
26
+ - `Spinner`, `Progress`, `SegmentedProgress`, `Skeleton` for in-place state; `StatusBar` for persistent status.
27
+ - `Toaster` with `toasts.info()` / `toasts.error()` for outcome messages; errors use the assertive live region.
28
+ - Motion tokens `--dur-fast`, `--dur-base` keep the acknowledgement under the 100 ms threshold.
29
+
30
+ ## Check
31
+
32
+ Click every action on the screen with the network throttled to "slow 3G". Within 100 ms something must change on or next to the control, and after completion the screen must say what happened.
@@ -0,0 +1,31 @@
1
+ # One primary action per view
2
+
3
+ Each view has exactly one filled button, for the thing most people came to do; everything else is quieter or behind a menu.
4
+
5
+ ## Why
6
+
7
+ Hick's law: decision time grows with the number and complexity of choices. Two filled buttons cancel each other out, and an action bar that wraps is a bar with too many actions.
8
+ Sources: https://lawsofux.com/hicks-law/ , Refactoring UI (hierarchy of actions) https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Filled accent for the primary action, outlined for secondary, plain text or icon-only for tertiary.
13
+ - Move rare or destructive actions into an overflow menu.
14
+ - Keep the primary action visible in the first viewport.
15
+
16
+ ## Don't
17
+
18
+ - Fill two buttons in the same toolbar.
19
+ - Let an action bar wrap onto a second line.
20
+ - Give the primary slot to "Cancel" or "Back".
21
+
22
+ ## In tsumikit
23
+
24
+ - `Button` `variant="primary"` for the one filled button; `variant="default"` for secondary, `variant="ghost"` or `IconButton` for tertiary.
25
+ - `SplitButton` when a primary action has variants; `Menu` for the overflow.
26
+ - `Toolbar` lays actions out with the shared control height.
27
+ - Lint rule `one-primary` flags more than one primary button in a view.
28
+
29
+ ## Check
30
+
31
+ Count filled buttons in each viewport-sized region of the screen. The answer is one. Then shrink the window to 40rem: no action bar may wrap.
@@ -0,0 +1,31 @@
1
+ # Emphasise by de-emphasising
2
+
3
+ Make the important thing stand out by making everything around it quieter, not bigger.
4
+
5
+ ## Why
6
+
7
+ Refactoring UI: hierarchy is built with weight and colour first, size last. When every element shouts, nothing does. Three text levels are enough for most screens: a title, values, labels.
8
+ Source: https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Title: large and bold. Value: normal weight, full contrast. Label: small, muted.
13
+ - Lower the contrast of supporting text instead of raising the size of primary text.
14
+ - Keep muted text at or above 4.5:1 contrast; faint text is for placeholders and disabled states only.
15
+
16
+ ## Don't
17
+
18
+ - Use size alone to signal importance.
19
+ - Use four or more text styles on one card.
20
+ - Push secondary text so faint that it fails contrast.
21
+
22
+ ## In tsumikit
23
+
24
+ - Text colour levels: `--text` for values, `--text-muted` for labels (>= 4.5:1), `--text-faint` for placeholder and disabled text only (~3:1).
25
+ - `Heading` for titles; `Text` with `tone="muted"` for labels and `variant="caption"` for small supporting copy.
26
+ - Weight tokens `--fw-normal`, `--fw-medium`, `--fw-semibold`, `--fw-bold`; size tokens `--fs-xs` to `--fs-3xl`.
27
+ - `KeyValue`, `Metric`, `StatTile` already encode the title / value / label structure.
28
+
29
+ ## Check
30
+
31
+ Squint at the screen (or blur a screenshot). You should see one title, a few strong values, and quiet labels. Run a contrast check on muted text: 4.5:1 or better.
@@ -0,0 +1,30 @@
1
+ # Labels are a last resort
2
+
3
+ Show a label only when the value cannot explain itself; when you do, make it small and quiet so the value carries the weight.
4
+
5
+ ## Why
6
+
7
+ Refactoring UI: "Year: 2023" makes the eye scan words, "2023" alone reads as a fact. Labels at the same weight as values double the text and halve the information density.
8
+ Source: https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Drop labels where the format is self-evident: years, file sizes, durations, email addresses.
13
+ - Fold the label into the value where it reads naturally ("3 files", "Korean audio").
14
+ - When a label stays, set it small, muted, often uppercase and tracked, with the value in full contrast.
15
+ - Lay out label and value pairs as a strip or grid that reads in one glance.
16
+
17
+ ## Don't
18
+
19
+ - Write "Label: value" lists in the same weight and colour.
20
+ - Separate related facts with " · " in a single run of text where a strip would scan faster.
21
+
22
+ ## In tsumikit
23
+
24
+ - `KeyValue` renders muted label and strong value; `Metric` and `StatTile` for numeric facts.
25
+ - `Text` `variant="eyebrow"` for small uppercase labels; `tone="muted"` for the label colour `--text-muted`.
26
+ - `--font-label` and `--font-numeric` (default to sans; opt into mono for data-dense views).
27
+
28
+ ## Check
29
+
30
+ Read each label on the screen and ask whether the value would be ambiguous without it. Remove the ones that would not. Remaining labels must be visibly quieter than their values.
@@ -0,0 +1,30 @@
1
+ # Tint your greys
2
+
3
+ Neutral greys look dead on screen; give every grey a slight shared hue so the whole page has one mood.
4
+
5
+ ## Why
6
+
7
+ Refactoring UI: pure greys read as "admin panel". Saturating them a little, cool or warm, makes the palette feel intentional. Material's tonal surfaces follow the same idea: surfaces are tinted shades of one hue.
8
+ Sources: https://www.refactoringui.com/ , https://m3.material.io/foundations
9
+
10
+ ## Do
11
+
12
+ - Pick one hue for the whole neutral ramp and tint backgrounds, borders and muted text with it.
13
+ - Keep lightness steps the same as a neutral ramp; only the hue changes.
14
+ - Let the accent colour and the grey tint come from nearby hues so they agree.
15
+
16
+ ## Don't
17
+
18
+ - Use `#2a2a2a`-style neutral hexes.
19
+ - Mix cool greys in one component with warm greys in another.
20
+ - Hard-code any grey in a component; read it from the theme.
21
+
22
+ ## In tsumikit
23
+
24
+ - Theme ramps `--c-bg`, `--c-bg-elev`, `--c-bg-elev-2`, `--c-surface`, `--c-border`, `--c-text-muted`, `--c-text-faint` are tinted per theme; components read the semantic aliases `--bg`, `--bg-elevated`, `--bg-elevated-2`, `--surface`, `--border`, `--text-muted`.
25
+ - `ThemePicker` and `chooseTheme()` switch the whole ramp at once; `Swatch` previews it.
26
+ - Lint rule `no-raw-color` rejects hex or rgb literals in app styles.
27
+
28
+ ## Check
29
+
30
+ Sample three greys from a screenshot (background, card, border). Each must have non-zero saturation in HSL and the same hue family.
@@ -0,0 +1,30 @@
1
+ # Depth from shade, not outlines
2
+
3
+ Layers are told apart by background lightness and soft shadow; borders are faint or absent.
4
+
5
+ ## Why
6
+
7
+ Refactoring UI and Apple's dark-mode guidance: in dark themes the base is near black and each level above it is a few percent lighter. When every panel has a strong border, the borders become the loudest thing on the page and the content loses.
8
+ Sources: https://www.refactoringui.com/ , https://developer.apple.com/design/human-interface-guidelines/
9
+
10
+ ## Do
11
+
12
+ - Stack `--bg` < `--bg-elevated` < `--bg-elevated-2` < `--surface` as you go up.
13
+ - Use a single light direction for shadows and keep them soft.
14
+ - Reserve visible borders for inputs and for cases where two equal-lightness areas must be separated.
15
+
16
+ ## Don't
17
+
18
+ - Put a 1px solid border on every card, table and panel.
19
+ - Combine a strong border with a shadow on the same element.
20
+ - Lighten text and darken background in the same step; adjust one at a time.
21
+
22
+ ## In tsumikit
23
+
24
+ - Elevation tokens `--bg`, `--bg-elevated`, `--bg-elevated-2`, `--surface`; `--border` for faint lines, `--border-strong` only where needed.
25
+ - Shadow tokens `--shadow-sm`, `--shadow-md`, `--shadow-lg`.
26
+ - `Card` reads surface fill and edge from `--surface-fill` / `--surface-edge`; look tokens `--raised-fill`, `--overlay-fill` cover popovers and menus.
27
+
28
+ ## Check
29
+
30
+ Desaturate a screenshot and count the borders. Each one should be doing something spacing or shade could not. Layers must still read as layers with all borders hidden.
@@ -0,0 +1,31 @@
1
+ # One accent, used sparingly
2
+
3
+ A single accent hue marks the primary action, the active item and focus; status colours mean status and nothing else.
4
+
5
+ ## Why
6
+
7
+ Von Restorff effect: the one item that differs is the one noticed and remembered. An accent used everywhere signals nothing. Separating accent from green, amber and red keeps "here" distinct from "ok", "warning", "error".
8
+ Source: https://lawsofux.com/von-restorff-effect/
9
+
10
+ ## Do
11
+
12
+ - Accent only on: the primary button, the active nav or tab, the focus ring, a selected row.
13
+ - Green, amber, red, blue only for semantic state; pair them with an icon or text.
14
+ - Keep counts, badges and chips neutral unless they carry state.
15
+
16
+ ## Don't
17
+
18
+ - Colour every nav item or badge with the accent.
19
+ - Pick an accent that resembles one of the status colours.
20
+ - Use colour as the only carrier of meaning.
21
+
22
+ ## In tsumikit
23
+
24
+ - `--accent`, `--accent-dim`, `--text-on-accent` for the single accent; themes keep the accent distinct from the status hues.
25
+ - Status tokens `--ok`, `--warn`, `--danger`, `--info`; `Badge`, `Dot`, `Callout` take a `tone` and always pair colour with an icon or label.
26
+ - `canonicalTone()` normalises tone names so there is one vocabulary.
27
+ - Lint rule `no-raw-color` keeps app styles on these tokens.
28
+
29
+ ## Check
30
+
31
+ List every element using the accent colour. Each must be one of: primary action, active item, focus, selection. Then check every green, amber or red element has a non-colour cue beside it.
@@ -0,0 +1,30 @@
1
+ # A spacing scale, and more room than feels right
2
+
3
+ All spacing comes from a fixed scale; start with too much whitespace, then remove some.
4
+
5
+ ## Why
6
+
7
+ Refactoring UI: cramped layouts feel cheap even when every pixel is correct, and arbitrary gaps make a page look accidental. A scale (4, 8, 12, 16, 24, 32, 48, 64) with no in-between values keeps rhythm consistent without thought.
8
+ Source: https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Use scale steps for padding, gaps and margins; jump a step rather than nudging by 2px.
13
+ - Give cards and sections generous inner padding (16 or 24), and larger gaps between sections than inside them.
14
+ - Align every element to an edge, grid or baseline that other elements also use.
15
+
16
+ ## Don't
17
+
18
+ - Use 10px, 14px, 18px or other off-scale values.
19
+ - Use the same gap everywhere; it flattens grouping (see "Group with space").
20
+ - Reach for `margin-top: 3px` to fix alignment; fix the line height or the scale step.
21
+
22
+ ## In tsumikit
23
+
24
+ - Spacing tokens `--sp-1` (4px) through `--sp-12` (48px), with `--sp-0-5` and `--sp-1-5` for hairline cases.
25
+ - Layouts `Stack`, `Cluster`, `AutoGrid`, `Container` take gaps from the scale; `--content-max` caps reading width.
26
+ - Lint rule `spacing-scale` flags raw pixel spacing in app styles.
27
+
28
+ ## Check
29
+
30
+ Inspect padding and gap values on the screen; every one must be a scale step. Then double the main gaps in dev tools; if it looks better, the original was too tight.
@@ -0,0 +1,31 @@
1
+ # Group with space, not boxes
2
+
3
+ Related items sit closer together or on a shared background; unrelated items are separated by more space, not by borders.
4
+
5
+ ## Why
6
+
7
+ Gestalt laws of proximity and common region: things close together, or inside one region, are read as a group. Space between groups must be clearly larger than space within them, or the grouping disappears.
8
+ Sources: https://lawsofux.com/law-of-proximity/ , https://lawsofux.com/law-of-common-region/
9
+
10
+ ## Do
11
+
12
+ - Within a group: one small step (4 or 8). Between groups: at least two steps larger (24 or 32).
13
+ - Use a shared background tint to group without a border.
14
+ - Put a label or heading closer to the content it describes than to the content above it.
15
+
16
+ ## Don't
17
+
18
+ - Space twelve items evenly and expect the reader to find the four groups.
19
+ - Draw a border around each group when a gap would do.
20
+ - Let a section heading float equidistant between two sections.
21
+
22
+ ## In tsumikit
23
+
24
+ - `Stack` with a per-group gap and a larger gap between stacks; `Cluster` for inline groups.
25
+ - `Card` and `Fieldset` for common region; `Divider` only when space cannot separate.
26
+ - `SectionHeader` attaches a heading to the block that follows.
27
+ - Spacing tokens `--sp-2`, `--sp-6`, `--sp-8` give the within / between contrast.
28
+
29
+ ## Check
30
+
31
+ Blur a screenshot until text is unreadable. The groups you intended must still be visible as clusters. If you see one even grid, the gaps are too uniform.
@@ -0,0 +1,33 @@
1
+ # A real typeface, a fixed scale
2
+
3
+ Bundle a typeface designed for screens and use a fixed type scale with few weights.
4
+
5
+ ## Why
6
+
7
+ Practical Typography and Refactoring UI: typography carries most of an interface. System font fallback changes the design per machine; a flat scale (heading same size as body) removes hierarchy. A typeface such as Inter, Geist or IBM Plex Sans is built for UI sizes.
8
+ Sources: https://practicaltypography.com/summary-of-key-rules.html , https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Bundle the font files with the app; do not depend on the user having the face installed.
13
+ - Use the kit's type scale and at most two body weights plus one bold for titles.
14
+ - Tighten letter-spacing slightly on large headings; never on body text.
15
+ - Use real typographic characters: an ellipsis, en dash, multiplication sign, curly quotes.
16
+
17
+ ## Don't
18
+
19
+ - Set `font-family: system-ui` and hope.
20
+ - Introduce ad-hoc font sizes outside the scale.
21
+ - Use more than three weights on a page.
22
+
23
+ ## In tsumikit
24
+
25
+ - Inter bundled via `styles/font.css`; `--font-sans` is the default stack, `--font-mono` for code.
26
+ - Type scale `--fs-2xs` (11) through `--fs-3xl` (36), scaled by `--fs-scale` (see `FontScalePicker`).
27
+ - Weights `--fw-normal`, `--fw-medium`, `--fw-semibold`, `--fw-bold`; line heights `--lh-heading`, `--lh-normal`.
28
+ - `Heading` and `Text` cover titles, body, labels, captions and eyebrows.
29
+ - Lint rule `no-raw-font` flags font-family or font-size literals in app styles.
30
+
31
+ ## Check
32
+
33
+ Open dev tools on headings and body and list the font sizes in use. They must all be scale steps and the rendered family must be the bundled one, not a fallback. Count the weights: three at most.
@@ -0,0 +1,30 @@
1
+ # Monospace only for code
2
+
3
+ Use a monospace face only for things that are literally code-like: paths, hashes, identifiers, commands.
4
+
5
+ ## Why
6
+
7
+ Practical Typography: monospace is slower to read and takes more width per character. Set as prose or labels it makes an interface feel like a terminal and wastes space; set on genuine code it signals "copy this exactly".
8
+ Source: https://practicaltypography.com/summary-of-key-rules.html
9
+
10
+ ## Do
11
+
12
+ - Mono for file paths, commit hashes, IDs, shell commands and code blocks.
13
+ - Sans for labels, nav, buttons, descriptions, timestamps and numbers in running text.
14
+ - Tabular numerals (`font-variant-numeric: tabular-nums`) in tables and stats so columns line up without going mono.
15
+
16
+ ## Don't
17
+
18
+ - Set a whole sidebar or label system in mono.
19
+ - Use mono for numbers only to align them.
20
+ - Mix two mono faces.
21
+
22
+ ## In tsumikit
23
+
24
+ - `--font-mono` is only applied in code slots; `--font-numeric` and `--font-label` default to sans and can be switched to mono per app.
25
+ - `Text` `variant="code"`, `CodeBlock`, `Kbd`, `WorkingDir`, `GitRef` are the mono components.
26
+ - `Prose` and `renderMarkdown()` set inline code in mono and prose in sans.
27
+
28
+ ## Check
29
+
30
+ Select all monospace text on the screen. For each run ask: would a user copy this character by character? If not, it should be sans.
@@ -0,0 +1,29 @@
1
+ # Keep lines between 45 and 75 characters
2
+
3
+ Body text runs 45 to 75 characters per line so the eye finds the next line without effort.
4
+
5
+ ## Why
6
+
7
+ Practical Typography: lines shorter than about 45 characters break the rhythm; longer than about 75, the eye loses its place on the return sweep. Line length matters more than font size for reading comfort.
8
+ Source: https://practicaltypography.com/summary-of-key-rules.html
9
+
10
+ ## Do
11
+
12
+ - Cap reading columns with a max width in `ch` or `rem`, independent of the viewport.
13
+ - Raise line height slightly as lines get longer.
14
+ - On wide screens, add columns or margins rather than stretching the text.
15
+
16
+ ## Don't
17
+
18
+ - Let descriptions, prose or form help text run the full width of a 1600px window.
19
+ - Shrink the font to make long lines fit.
20
+
21
+ ## In tsumikit
22
+
23
+ - `--measure` sets the default reading width; `--content-max` caps page content.
24
+ - `Prose` and `EmptyState` descriptions are measure-limited; `Container` constrains layouts.
25
+ - Line-height tokens `--lh-normal`, `--lh-snug`.
26
+
27
+ ## Check
28
+
29
+ Count characters on a full line of body text at the widest supported viewport. Between 45 and 75.
@@ -0,0 +1,32 @@
1
+ # Let imagery set the mood, keep the chrome quiet
2
+
3
+ Content imagery carries atmosphere; UI chrome stays neutral, text over images sits on a scrim, and a missing image gets a designed placeholder.
4
+
5
+ ## Why
6
+
7
+ Aesthetic-usability effect: interfaces perceived as beautiful are perceived as easier to use and are forgiven more. A backdrop fading into the page does more than any amount of styled chrome. An empty grey box where an image should be breaks the spell instantly.
8
+ Sources: https://lawsofux.com/aesthetic-usability-effect/ , https://www.refactoringui.com/
9
+
10
+ ## Do
11
+
12
+ - Lead detail pages with a backdrop or hero image that fades into the page background.
13
+ - Place text over images only on a gradient scrim that guarantees contrast.
14
+ - Generate a placeholder from the item itself (initials, a hue derived from its name) when the image is missing or still loading.
15
+ - Give every meaningful image alt text; decorative images get an empty alt.
16
+
17
+ ## Don't
18
+
19
+ - Compete with the image using coloured chrome or heavy borders.
20
+ - Render white text directly on an unpredictable photo.
21
+ - Show an empty or broken-image box, even briefly.
22
+
23
+ ## In tsumikit
24
+
25
+ - `Backdrop` layout for the hero-with-fade; `Scrim` for readable text over images.
26
+ - `Artwork` shows a designed placeholder while loading or missing, via `artworkGradient()`, `artworkHue()` and `initials()`; `Avatar` does the same for people.
27
+ - `Skeleton` for placeholders that are shapes rather than pictures.
28
+ - Lint rule `img-alt` requires alt text on images.
29
+
30
+ ## Check
31
+
32
+ Load the page with images blocked. Every image slot must still show an intentional placeholder. Then re-enable images and verify text over them passes 4.5:1 at its lightest point.