@quo-systems/ui 0.1.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.
- package/LICENSE +202 -0
- package/NOTICE +6 -0
- package/README.md +26 -0
- package/css/base.css +155 -0
- package/css/components.css +314 -0
- package/css/theme-default.css +152 -0
- package/css/tokens.css +182 -0
- package/dist/css.d.ts +5 -0
- package/dist/css.js +6 -0
- package/dist/elements/theme.d.ts +9 -0
- package/dist/elements/theme.js +75 -0
- package/dist/tokens/livery-cli.d.ts +2 -0
- package/dist/tokens/livery-cli.js +29 -0
- package/dist/tokens/livery.d.ts +11 -0
- package/dist/tokens/livery.js +39 -0
- package/dist/tokens/manifest.d.ts +10 -0
- package/dist/tokens/manifest.js +105 -0
- package/elements/theme.ts +79 -0
- package/package.json +58 -0
- package/tokens/livery-cli.ts +33 -0
- package/tokens/livery.ts +58 -0
- package/tokens/manifest.ts +109 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// <nv-theme>: the theme choice, and nothing else.
|
|
3
|
+
//
|
|
4
|
+
// The buttons are the page's, server-rendered, one per theme the surface
|
|
5
|
+
// offers plus `auto` where it offers both. This element only wires them up:
|
|
6
|
+
// it reads the stored choice, writes data-theme on the document, and marks
|
|
7
|
+
// which button is pressed. With no script the buttons are still there and
|
|
8
|
+
// still labelled, and the page still paints, because `auto` is the state a
|
|
9
|
+
// document in no theme is already in.
|
|
10
|
+
//
|
|
11
|
+
// The store is one key, so the app, the screen and a website all read the
|
|
12
|
+
// same choice on the same origin.
|
|
13
|
+
|
|
14
|
+
export const THEME_KEY = 'nv.theme';
|
|
15
|
+
|
|
16
|
+
// Inline this in <head>, before any stylesheet, or the first paint is the
|
|
17
|
+
// wrong theme and the second one is a flash. It is the only script the kit
|
|
18
|
+
// asks a page to inline, and it is this short on purpose.
|
|
19
|
+
export const prepaint =
|
|
20
|
+
`try{var t=localStorage.getItem(${JSON.stringify(THEME_KEY)});` +
|
|
21
|
+
`if(t&&t!=='auto')document.documentElement.dataset.theme=t}catch(e){}`;
|
|
22
|
+
|
|
23
|
+
// Storage is gone in a private window, cleared by a browser that blocks site
|
|
24
|
+
// data, and throws outright in some embeddings. A theme control is a
|
|
25
|
+
// convenience, so every read and write fails quietly and the surface stays
|
|
26
|
+
// on `auto`.
|
|
27
|
+
function stored(): string | null {
|
|
28
|
+
try {
|
|
29
|
+
return localStorage.getItem(THEME_KEY);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function store(value: string): void {
|
|
36
|
+
try {
|
|
37
|
+
if (value === 'auto') localStorage.removeItem(THEME_KEY);
|
|
38
|
+
else localStorage.setItem(THEME_KEY, value);
|
|
39
|
+
} catch {
|
|
40
|
+
/* a choice that cannot be remembered still applies to this page */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function applyTheme(value: string, root: HTMLElement): void {
|
|
45
|
+
if (value === 'auto') delete root.dataset.theme;
|
|
46
|
+
else root.dataset.theme = value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class NvTheme extends HTMLElement {
|
|
50
|
+
connectedCallback(): void {
|
|
51
|
+
this.addEventListener('click', this);
|
|
52
|
+
this.mark(stored() ?? 'auto');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
disconnectedCallback(): void {
|
|
56
|
+
this.removeEventListener('click', this);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
handleEvent(event: Event): void {
|
|
60
|
+
const button = (event.target as Element | null)?.closest<HTMLElement>('[data-theme]');
|
|
61
|
+
if (!button || !this.contains(button)) return;
|
|
62
|
+
const chosen = button.dataset.theme ?? 'auto';
|
|
63
|
+
store(chosen);
|
|
64
|
+
this.mark(chosen);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The document and the buttons say the same thing, always, and the element
|
|
68
|
+
// is the only writer of either.
|
|
69
|
+
private mark(chosen: string): void {
|
|
70
|
+
applyTheme(chosen, this.ownerDocument.documentElement);
|
|
71
|
+
for (const button of this.querySelectorAll<HTMLElement>('[data-theme]')) {
|
|
72
|
+
button.setAttribute('aria-pressed', String(button.dataset.theme === chosen));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof customElements !== 'undefined' && !customElements.get('nv-theme')) {
|
|
78
|
+
customElements.define('nv-theme', NvTheme);
|
|
79
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quo-systems/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The kit: one token contract, one baseline, and the primitives a screen and a website both need. No framework, no dependency, and no word about harbor, ward or being.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"design-tokens",
|
|
7
|
+
"custom-elements",
|
|
8
|
+
"css",
|
|
9
|
+
"accessibility"
|
|
10
|
+
],
|
|
11
|
+
"author": "Razvan Gherghina",
|
|
12
|
+
"license": "Apache-2.0",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.18"
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"nv-livery": "./dist/tokens/livery-cli.js"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./tokens": {
|
|
22
|
+
"types": "./dist/tokens/manifest.d.ts",
|
|
23
|
+
"default": "./dist/tokens/manifest.js"
|
|
24
|
+
},
|
|
25
|
+
"./livery": {
|
|
26
|
+
"types": "./dist/tokens/livery.d.ts",
|
|
27
|
+
"default": "./dist/tokens/livery.js"
|
|
28
|
+
},
|
|
29
|
+
"./css": {
|
|
30
|
+
"types": "./dist/css.d.ts",
|
|
31
|
+
"default": "./dist/css.js"
|
|
32
|
+
},
|
|
33
|
+
"./elements/theme": {
|
|
34
|
+
"types": "./dist/elements/theme.d.ts",
|
|
35
|
+
"default": "./dist/elements/theme.js"
|
|
36
|
+
},
|
|
37
|
+
"./css/tokens.css": "./css/tokens.css",
|
|
38
|
+
"./css/theme-default.css": "./css/theme-default.css",
|
|
39
|
+
"./css/base.css": "./css/base.css",
|
|
40
|
+
"./css/components.css": "./css/components.css"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node build.ts",
|
|
44
|
+
"prepublishOnly": "test \"$QUO_GATED\" = 1 || { echo 'publish from the root, gated once: npm run release' >&2; exit 1; }"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist",
|
|
51
|
+
"css",
|
|
52
|
+
"tokens",
|
|
53
|
+
"elements",
|
|
54
|
+
"README.md",
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"NOTICE"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
// `nv-livery [dir]` compiles the design in a folder: it reads `livery.json`
|
|
4
|
+
// and writes `tokens.css` beside it. A design holds no build script of its
|
|
5
|
+
// own, so there is one compiler in the tree and every design calls it.
|
|
6
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { renderTokensCss, type Livery } from './livery.ts';
|
|
9
|
+
|
|
10
|
+
const dir = resolve(process.argv[2] ?? '.');
|
|
11
|
+
const from = resolve(dir, 'livery.json');
|
|
12
|
+
const to = resolve(dir, 'tokens.css');
|
|
13
|
+
|
|
14
|
+
let livery: Livery;
|
|
15
|
+
try {
|
|
16
|
+
livery = JSON.parse(readFileSync(from, 'utf8')) as Livery;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
process.stderr.write(`nv-livery: ${(err as Error).message}\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let css: string;
|
|
23
|
+
try {
|
|
24
|
+
css = renderTokensCss(livery);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
process.stderr.write(`nv-livery: ${(err as Error).message}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
writeFileSync(to, css);
|
|
31
|
+
process.stdout.write(
|
|
32
|
+
`nv-livery: ${Object.keys(livery.themes).length} themes from the ${livery.name} design\n`,
|
|
33
|
+
);
|
package/tokens/livery.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The livery compiler. A design is one document, `livery.json`, and this
|
|
3
|
+
// turns it into the CSS that document means. The compiler is the kit's
|
|
4
|
+
// because it is the same for every design; the palette is the design's
|
|
5
|
+
// because it is nobody else's.
|
|
6
|
+
import { THEME_SLOTS_REQUIRED, designMaySet } from './manifest.ts';
|
|
7
|
+
|
|
8
|
+
export type Theme = {
|
|
9
|
+
colorScheme: 'light' | 'dark';
|
|
10
|
+
slots: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type Livery = {
|
|
14
|
+
name: string;
|
|
15
|
+
themes: Record<string, Theme>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function missingSlots(slots: Record<string, string>): string[] {
|
|
19
|
+
return THEME_SLOTS_REQUIRED.filter((slot) => slots[slot] === undefined);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A design that reaches for structure is refused by name, so the message says
|
|
23
|
+
// which property and not merely that something was wrong.
|
|
24
|
+
export function forbiddenSlots(slots: Record<string, string>): string[] {
|
|
25
|
+
return Object.keys(slots).filter((slot) => !designMaySet(slot));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Throws on the first theme that leaves a required slot undefined or sets one
|
|
29
|
+
// that belongs to the kit. A design either paints a whole face or it does not
|
|
30
|
+
// compile.
|
|
31
|
+
export function renderTokensCss(livery: Livery): string {
|
|
32
|
+
const blocks: string[] = [];
|
|
33
|
+
for (const [theme, { colorScheme, slots }] of Object.entries(livery.themes)) {
|
|
34
|
+
const missing = missingSlots(slots);
|
|
35
|
+
if (missing.length > 0) {
|
|
36
|
+
throw new Error(`${theme}: livery.json declares no value for:\n ${missing.join('\n ')}`);
|
|
37
|
+
}
|
|
38
|
+
const forbidden = forbiddenSlots(slots);
|
|
39
|
+
if (forbidden.length > 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${theme}: structure belongs to the kit, and livery.json sets:\n ${forbidden.join('\n ')}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
const declared = Object.entries(slots)
|
|
45
|
+
.map(([slot, value]) => ` ${slot}: ${value};`)
|
|
46
|
+
.join('\n');
|
|
47
|
+
blocks.push(
|
|
48
|
+
`:root[data-theme='${theme}'] {\n color-scheme: ${colorScheme};\n\n${declared}\n}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
`/*\n * Generated from livery.json. Never edit: run nv-livery.\n *\n` +
|
|
53
|
+
` * The ${livery.name} palette, colour and shadow only, worn by whichever\n` +
|
|
54
|
+
` * estate wears the ${livery.name} design. Declared unlayered, so it wins\n` +
|
|
55
|
+
` * over the kit's layered defaults without a specificity trick.\n */\n\n` +
|
|
56
|
+
`${blocks.join('\n\n')}\n`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// The contract, as data. Every rule about what a design may set and what it
|
|
3
|
+
// may not is one of these lists, so a validator and a reviewer read the same
|
|
4
|
+
// thing. `css/tokens.css` declares the structural side; a design declares the
|
|
5
|
+
// slots.
|
|
6
|
+
|
|
7
|
+
// The only width constants there are. Everything else derives from these or
|
|
8
|
+
// from data-tier, and a raw width media query in a component is illegal.
|
|
9
|
+
export const BREAKPOINTS = Object.freeze({ tablet: 768, desktop: 1280 });
|
|
10
|
+
|
|
11
|
+
// Root-only, immutable. A design that defines a property under any of these
|
|
12
|
+
// prefixes fails conformance.
|
|
13
|
+
export const STRUCTURAL_PREFIXES = Object.freeze([
|
|
14
|
+
'--nv-space-',
|
|
15
|
+
'--nv-radius-',
|
|
16
|
+
'--nv-text-',
|
|
17
|
+
'--nv-leading-',
|
|
18
|
+
'--nv-weight-',
|
|
19
|
+
'--nv-duration-',
|
|
20
|
+
'--nv-ease-',
|
|
21
|
+
'--nv-motion',
|
|
22
|
+
'--nv-z-',
|
|
23
|
+
'--nv-control-',
|
|
24
|
+
'--nv-icon-',
|
|
25
|
+
'--nv-tap-target',
|
|
26
|
+
'--nv-safe-',
|
|
27
|
+
'--nv-kb-inset',
|
|
28
|
+
'--nv-inset-bottom',
|
|
29
|
+
'--nv-page-',
|
|
30
|
+
'--nv-stack-gap',
|
|
31
|
+
'--nv-card-pad',
|
|
32
|
+
'--nv-form-gap',
|
|
33
|
+
'--nv-section-gap',
|
|
34
|
+
'--nv-header-h',
|
|
35
|
+
'--nv-tabbar-h',
|
|
36
|
+
'--nv-sidebar-w',
|
|
37
|
+
'--nv-rail-w',
|
|
38
|
+
'--nv-aside-w',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// A design may mint a colour under this prefix for a role of its own. Every
|
|
42
|
+
// read of one carries a muted fallback, so an unminted role paints quiet
|
|
43
|
+
// rather than nothing.
|
|
44
|
+
export const ROLE_COLOR_PREFIX = '--nv-color-role-';
|
|
45
|
+
|
|
46
|
+
// The slots a design owns, and every one of them is required in every theme
|
|
47
|
+
// it declares. A palette with a hole in it is a palette that paints a
|
|
48
|
+
// surprise on the one screen nobody opened.
|
|
49
|
+
export const THEME_SLOTS_REQUIRED = Object.freeze([
|
|
50
|
+
'--nv-color-bg',
|
|
51
|
+
'--nv-color-bg-raised',
|
|
52
|
+
'--nv-color-bg-raised-hover',
|
|
53
|
+
'--nv-color-bg-raised-strong',
|
|
54
|
+
'--nv-color-bg-sunken',
|
|
55
|
+
'--nv-color-bg-overlay',
|
|
56
|
+
'--nv-color-fg',
|
|
57
|
+
'--nv-color-fg-body',
|
|
58
|
+
'--nv-color-fg-muted',
|
|
59
|
+
'--nv-color-fg-subtle',
|
|
60
|
+
'--nv-color-fg-on-accent',
|
|
61
|
+
'--nv-color-border',
|
|
62
|
+
'--nv-color-border-strong',
|
|
63
|
+
'--nv-color-border-focus',
|
|
64
|
+
'--nv-color-accent',
|
|
65
|
+
'--nv-color-accent-hover',
|
|
66
|
+
'--nv-color-accent-active',
|
|
67
|
+
'--nv-color-accent-subtle',
|
|
68
|
+
'--nv-color-success',
|
|
69
|
+
'--nv-color-success-subtle',
|
|
70
|
+
'--nv-color-warning',
|
|
71
|
+
'--nv-color-warning-subtle',
|
|
72
|
+
'--nv-color-danger',
|
|
73
|
+
'--nv-color-danger-subtle',
|
|
74
|
+
'--nv-color-info',
|
|
75
|
+
'--nv-color-info-subtle',
|
|
76
|
+
'--nv-color-scroll-thumb',
|
|
77
|
+
'--nv-color-scroll-thumb-hover',
|
|
78
|
+
'--nv-shadow-sm',
|
|
79
|
+
'--nv-shadow-md',
|
|
80
|
+
'--nv-shadow-lg',
|
|
81
|
+
'--nv-shadow-xl',
|
|
82
|
+
'--nv-shadow-focus',
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
// Overridable, and defaulted at the root from the required ones, so a design
|
|
86
|
+
// that says nothing about them still paints.
|
|
87
|
+
export const THEME_SLOTS_OPTIONAL = Object.freeze([
|
|
88
|
+
'--nv-color-link',
|
|
89
|
+
'--nv-color-link-hover',
|
|
90
|
+
'--nv-color-border-subtle',
|
|
91
|
+
'--nv-color-danger-hover',
|
|
92
|
+
'--nv-color-fg-on-danger',
|
|
93
|
+
'--nv-brand-healthy',
|
|
94
|
+
'--nv-brand-reconnecting',
|
|
95
|
+
'--nv-brand-offline',
|
|
96
|
+
'--nv-brand-logo-size',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
// A design may point these at its own faces. It ships the files itself.
|
|
100
|
+
export const FONT_SLOTS = Object.freeze(['--nv-font-sans', '--nv-font-mono', '--nv-font-display']);
|
|
101
|
+
|
|
102
|
+
// True when a design may set this property. Every other `--nv-` name is
|
|
103
|
+
// structure and belongs to the kit.
|
|
104
|
+
export function designMaySet(property: string): boolean {
|
|
105
|
+
if (STRUCTURAL_PREFIXES.some((prefix) => property.startsWith(prefix))) return false;
|
|
106
|
+
if (property.startsWith(ROLE_COLOR_PREFIX)) return true;
|
|
107
|
+
if (FONT_SLOTS.includes(property)) return true;
|
|
108
|
+
return THEME_SLOTS_REQUIRED.includes(property) || THEME_SLOTS_OPTIONAL.includes(property);
|
|
109
|
+
}
|