@quo-systems/ui 0.1.0 → 0.1.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.
@@ -1,9 +1,3 @@
1
- export declare const THEME_KEY = "nv.theme";
1
+ export declare const THEME_COOKIE = "nv.theme";
2
2
  export declare const prepaint: string;
3
3
  export declare function applyTheme(value: string, root: HTMLElement): void;
4
- export declare class NvTheme extends HTMLElement {
5
- connectedCallback(): void;
6
- disconnectedCallback(): void;
7
- handleEvent(event: Event): void;
8
- private mark;
9
- }
@@ -8,32 +8,58 @@
8
8
  // still labelled, and the page still paints, because `auto` is the state a
9
9
  // document in no theme is already in.
10
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
- export const THEME_KEY = 'nv.theme';
11
+ // The choice is a cookie and not browser storage, and that is the whole
12
+ // reason this element exists rather than three lines in a page. A cookie
13
+ // carries a Domain, so a person who picks dark on a world's website has
14
+ // picked dark on its quo subdomain and in its app: one choice, one surface
15
+ // to them, however many origins it is to us. Browser storage is per origin
16
+ // and would make each of those a stranger to the others.
17
+ //
18
+ // A server may read it too, since a cookie is sent with the request, but
19
+ // nothing here needs that: the snippet below runs before the first paint
20
+ // and no page waits on it.
21
+ export const THEME_COOKIE = 'nv.theme';
14
22
  // Inline this in <head>, before any stylesheet, or the first paint is the
15
23
  // wrong theme and the second one is a flash. It is the only script the kit
16
24
  // asks a page to inline, and it is this short on purpose.
17
- export const prepaint = `try{var t=localStorage.getItem(${JSON.stringify(THEME_KEY)});` +
18
- `if(t&&t!=='auto')document.documentElement.dataset.theme=t}catch(e){}`;
19
- // Storage is gone in a private window, cleared by a browser that blocks site
20
- // data, and throws outright in some embeddings. A theme control is a
21
- // convenience, so every read and write fails quietly and the surface stays
22
- // on `auto`.
23
- function stored() {
25
+ export const prepaint = `try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE.replace('.', '\\.')}=([^;]*)/);` +
26
+ `if(m&&m[1]!=='auto')document.documentElement.dataset.theme=decodeURIComponent(m[1])}catch(e){}`;
27
+ function stored(doc) {
24
28
  try {
25
- return localStorage.getItem(THEME_KEY);
29
+ const m = doc.cookie.match(new RegExp(`(?:^|; )${THEME_COOKIE.replace('.', '\\.')}=([^;]*)`));
30
+ return m ? decodeURIComponent(m[1]) : null;
26
31
  }
27
32
  catch {
28
33
  return null;
29
34
  }
30
35
  }
31
- function store(value) {
36
+ /*
37
+ * The domain a choice is remembered for: the registrable one, so every
38
+ * subdomain of a world shares it. Two labels, which is right for every name
39
+ * this repository serves and wrong for a two-label public suffix such as
40
+ * `co.uk`; a world under one of those sets its own Domain and this is where
41
+ * that would be read from.
42
+ *
43
+ * A bare host or an address gets no Domain at all, because a browser rejects
44
+ * one there and would drop the cookie entirely.
45
+ */
46
+ function domainOf(host) {
47
+ if (!host || host === 'localhost' || /^[\d.]+$/.test(host) || !host.includes('.'))
48
+ return null;
49
+ return `.${host.split('.').slice(-2).join('.')}`;
50
+ }
51
+ function store(value, doc) {
32
52
  try {
33
- if (value === 'auto')
34
- localStorage.removeItem(THEME_KEY);
35
- else
36
- localStorage.setItem(THEME_KEY, value);
53
+ const parts = [
54
+ `${THEME_COOKIE}=${encodeURIComponent(value)}`,
55
+ 'Path=/',
56
+ 'SameSite=Lax',
57
+ 'Max-Age=31536000',
58
+ ];
59
+ const domain = domainOf(doc.location?.hostname ?? '');
60
+ if (domain)
61
+ parts.push(`Domain=${domain}`);
62
+ doc.cookie = parts.join('; ');
37
63
  }
38
64
  catch {
39
65
  /* a choice that cannot be remembered still applies to this page */
@@ -45,31 +71,40 @@ export function applyTheme(value, root) {
45
71
  else
46
72
  root.dataset.theme = value;
47
73
  }
48
- export class NvTheme extends HTMLElement {
49
- connectedCallback() {
50
- this.addEventListener('click', this);
51
- this.mark(stored() ?? 'auto');
52
- }
53
- disconnectedCallback() {
54
- this.removeEventListener('click', this);
55
- }
56
- handleEvent(event) {
57
- const button = event.target?.closest('[data-theme]');
58
- if (!button || !this.contains(button))
59
- return;
60
- const chosen = button.dataset.theme ?? 'auto';
61
- store(chosen);
62
- this.mark(chosen);
63
- }
64
- // The document and the buttons say the same thing, always, and the element
65
- // is the only writer of either.
66
- mark(chosen) {
67
- applyTheme(chosen, this.ownerDocument.documentElement);
68
- for (const button of this.querySelectorAll('[data-theme]')) {
69
- button.setAttribute('aria-pressed', String(button.dataset.theme === chosen));
74
+ /*
75
+ * The class is built inside this function and not at the top of the file,
76
+ * so importing this module where there is no DOM is safe. A site that
77
+ * renders on request imports it on the server to inline `prepaint`, and a
78
+ * class extending HTMLElement at module scope would end that render with a
79
+ * ReferenceError before a page ever reached a browser.
80
+ */
81
+ function define() {
82
+ if (typeof customElements === 'undefined' || customElements.get('nv-theme'))
83
+ return;
84
+ customElements.define('nv-theme', class extends HTMLElement {
85
+ connectedCallback() {
86
+ this.addEventListener('click', this);
87
+ this.mark(stored(this.ownerDocument) ?? 'auto');
70
88
  }
71
- }
72
- }
73
- if (typeof customElements !== 'undefined' && !customElements.get('nv-theme')) {
74
- customElements.define('nv-theme', NvTheme);
89
+ disconnectedCallback() {
90
+ this.removeEventListener('click', this);
91
+ }
92
+ handleEvent(event) {
93
+ const button = event.target?.closest('[data-theme]');
94
+ if (!button || !this.contains(button))
95
+ return;
96
+ const chosen = button.dataset.theme ?? 'auto';
97
+ store(chosen, this.ownerDocument);
98
+ this.mark(chosen);
99
+ }
100
+ // The document and the buttons say the same thing, always, and the
101
+ // element is the only writer of either.
102
+ mark(chosen) {
103
+ applyTheme(chosen, this.ownerDocument.documentElement);
104
+ for (const button of this.querySelectorAll('[data-theme]')) {
105
+ button.setAttribute('aria-pressed', String(button.dataset.theme === chosen));
106
+ }
107
+ }
108
+ });
75
109
  }
110
+ define();
@@ -12,6 +12,37 @@ export function missingSlots(slots) {
12
12
  export function forbiddenSlots(slots) {
13
13
  return Object.keys(slots).filter((slot) => !designMaySet(slot));
14
14
  }
15
+ const declare = (slots, pad) => Object.entries(slots)
16
+ .map(([slot, value]) => `${pad}${slot}: ${value};`)
17
+ .join('\n');
18
+ /*
19
+ * What a document in no theme wears.
20
+ *
21
+ * `auto` is not a theme, it is the absence of a choice, and a design whose
22
+ * every block keys on data-theme paints nothing at all when nobody has
23
+ * chosen. So the first theme declared is also written at the bare root, and
24
+ * where a design has both light and dark the system preference picks between
25
+ * them for a reader who has told their system and not this page.
26
+ *
27
+ * The dark block is guarded against an explicit light, or a reader who chose
28
+ * light on a dark-preferring system would be overruled by their own machine.
29
+ */
30
+ function systemBlocks(livery) {
31
+ const themes = Object.entries(livery.themes);
32
+ const [firstName, first] = themes[0];
33
+ const out = [
34
+ `:root {\n color-scheme: ${first.colorScheme};\n\n${declare(first.slots, ' ')}\n}`,
35
+ ];
36
+ const other = themes.find(([name]) => name !== firstName && name === 'dark') ??
37
+ themes.find(([name]) => name !== firstName);
38
+ if (themes.length > 1 && other && first.colorScheme !== other[1].colorScheme) {
39
+ const [, o] = other;
40
+ out.push(`@media (prefers-color-scheme: ${o.colorScheme}) {\n` +
41
+ ` :root:not([data-theme='${firstName}']) {\n` +
42
+ ` color-scheme: ${o.colorScheme};\n\n${declare(o.slots, ' ')}\n }\n}`);
43
+ }
44
+ return out;
45
+ }
15
46
  // Throws on the first theme that leaves a required slot undefined or sets one
16
47
  // that belongs to the kit. A design either paints a whole face or it does not
17
48
  // compile.
@@ -26,14 +57,13 @@ export function renderTokensCss(livery) {
26
57
  if (forbidden.length > 0) {
27
58
  throw new Error(`${theme}: structure belongs to the kit, and livery.json sets:\n ${forbidden.join('\n ')}`);
28
59
  }
29
- const declared = Object.entries(slots)
30
- .map(([slot, value]) => ` ${slot}: ${value};`)
31
- .join('\n');
32
- blocks.push(`:root[data-theme='${theme}'] {\n color-scheme: ${colorScheme};\n\n${declared}\n}`);
60
+ blocks.push(`:root[data-theme='${theme}'] {\n color-scheme: ${colorScheme};\n\n${declare(slots, ' ')}\n}`);
33
61
  }
34
62
  return (`/*\n * Generated from livery.json. Never edit: run nv-livery.\n *\n` +
35
63
  ` * The ${livery.name} palette, colour and shadow only, worn by whichever\n` +
36
64
  ` * estate wears the ${livery.name} design. Declared unlayered, so it wins\n` +
37
- ` * over the kit's layered defaults without a specificity trick.\n */\n\n` +
38
- `${blocks.join('\n\n')}\n`);
65
+ ` * over the kit's layered defaults without a specificity trick.\n *\n` +
66
+ ` * The first block is what a document in no theme wears, since auto is\n` +
67
+ ` * the absence of a choice and not a theme of its own.\n */\n\n` +
68
+ `${[...systemBlocks(livery), ...blocks].join('\n\n')}\n`);
39
69
  }
package/elements/theme.ts CHANGED
@@ -8,34 +8,61 @@
8
8
  // still labelled, and the page still paints, because `auto` is the state a
9
9
  // document in no theme is already in.
10
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.
11
+ // The choice is a cookie and not browser storage, and that is the whole
12
+ // reason this element exists rather than three lines in a page. A cookie
13
+ // carries a Domain, so a person who picks dark on a world's website has
14
+ // picked dark on its quo subdomain and in its app: one choice, one surface
15
+ // to them, however many origins it is to us. Browser storage is per origin
16
+ // and would make each of those a stranger to the others.
17
+ //
18
+ // A server may read it too, since a cookie is sent with the request, but
19
+ // nothing here needs that: the snippet below runs before the first paint
20
+ // and no page waits on it.
13
21
 
14
- export const THEME_KEY = 'nv.theme';
22
+ export const THEME_COOKIE = 'nv.theme';
15
23
 
16
24
  // Inline this in <head>, before any stylesheet, or the first paint is the
17
25
  // wrong theme and the second one is a flash. It is the only script the kit
18
26
  // asks a page to inline, and it is this short on purpose.
19
27
  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){}`;
28
+ `try{var m=document.cookie.match(/(?:^|; )${THEME_COOKIE.replace('.', '\\.')}=([^;]*)/);` +
29
+ `if(m&&m[1]!=='auto')document.documentElement.dataset.theme=decodeURIComponent(m[1])}catch(e){}`;
22
30
 
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 {
31
+ function stored(doc: Document): string | null {
28
32
  try {
29
- return localStorage.getItem(THEME_KEY);
33
+ const m = doc.cookie.match(new RegExp(`(?:^|; )${THEME_COOKIE.replace('.', '\\.')}=([^;]*)`));
34
+ return m ? decodeURIComponent(m[1]) : null;
30
35
  } catch {
31
36
  return null;
32
37
  }
33
38
  }
34
39
 
35
- function store(value: string): void {
40
+ /*
41
+ * The domain a choice is remembered for: the registrable one, so every
42
+ * subdomain of a world shares it. Two labels, which is right for every name
43
+ * this repository serves and wrong for a two-label public suffix such as
44
+ * `co.uk`; a world under one of those sets its own Domain and this is where
45
+ * that would be read from.
46
+ *
47
+ * A bare host or an address gets no Domain at all, because a browser rejects
48
+ * one there and would drop the cookie entirely.
49
+ */
50
+ function domainOf(host: string): string | null {
51
+ if (!host || host === 'localhost' || /^[\d.]+$/.test(host) || !host.includes('.')) return null;
52
+ return `.${host.split('.').slice(-2).join('.')}`;
53
+ }
54
+
55
+ function store(value: string, doc: Document): void {
36
56
  try {
37
- if (value === 'auto') localStorage.removeItem(THEME_KEY);
38
- else localStorage.setItem(THEME_KEY, value);
57
+ const parts = [
58
+ `${THEME_COOKIE}=${encodeURIComponent(value)}`,
59
+ 'Path=/',
60
+ 'SameSite=Lax',
61
+ 'Max-Age=31536000',
62
+ ];
63
+ const domain = domainOf(doc.location?.hostname ?? '');
64
+ if (domain) parts.push(`Domain=${domain}`);
65
+ doc.cookie = parts.join('; ');
39
66
  } catch {
40
67
  /* a choice that cannot be remembered still applies to this page */
41
68
  }
@@ -46,34 +73,46 @@ export function applyTheme(value: string, root: HTMLElement): void {
46
73
  else root.dataset.theme = value;
47
74
  }
48
75
 
49
- export class NvTheme extends HTMLElement {
50
- connectedCallback(): void {
51
- this.addEventListener('click', this);
52
- this.mark(stored() ?? 'auto');
53
- }
76
+ /*
77
+ * The class is built inside this function and not at the top of the file,
78
+ * so importing this module where there is no DOM is safe. A site that
79
+ * renders on request imports it on the server to inline `prepaint`, and a
80
+ * class extending HTMLElement at module scope would end that render with a
81
+ * ReferenceError before a page ever reached a browser.
82
+ */
83
+ function define(): void {
84
+ if (typeof customElements === 'undefined' || customElements.get('nv-theme')) return;
54
85
 
55
- disconnectedCallback(): void {
56
- this.removeEventListener('click', this);
57
- }
86
+ customElements.define(
87
+ 'nv-theme',
88
+ class extends HTMLElement {
89
+ connectedCallback(): void {
90
+ this.addEventListener('click', this);
91
+ this.mark(stored(this.ownerDocument) ?? 'auto');
92
+ }
58
93
 
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
- }
94
+ disconnectedCallback(): void {
95
+ this.removeEventListener('click', this);
96
+ }
66
97
 
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
- }
98
+ handleEvent(event: Event): void {
99
+ const button = (event.target as Element | null)?.closest<HTMLElement>('[data-theme]');
100
+ if (!button || !this.contains(button)) return;
101
+ const chosen = button.dataset.theme ?? 'auto';
102
+ store(chosen, this.ownerDocument);
103
+ this.mark(chosen);
104
+ }
76
105
 
77
- if (typeof customElements !== 'undefined' && !customElements.get('nv-theme')) {
78
- customElements.define('nv-theme', NvTheme);
106
+ // The document and the buttons say the same thing, always, and the
107
+ // element is the only writer of either.
108
+ mark(chosen: string): void {
109
+ applyTheme(chosen, this.ownerDocument.documentElement);
110
+ for (const button of this.querySelectorAll<HTMLElement>('[data-theme]')) {
111
+ button.setAttribute('aria-pressed', String(button.dataset.theme === chosen));
112
+ }
113
+ }
114
+ },
115
+ );
79
116
  }
117
+
118
+ define();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quo-systems/ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
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
5
  "keywords": [
6
6
  "design-tokens",
package/tokens/livery.ts CHANGED
@@ -25,6 +25,42 @@ export function forbiddenSlots(slots: Record<string, string>): string[] {
25
25
  return Object.keys(slots).filter((slot) => !designMaySet(slot));
26
26
  }
27
27
 
28
+ const declare = (slots: Record<string, string>, pad: string) =>
29
+ Object.entries(slots)
30
+ .map(([slot, value]) => `${pad}${slot}: ${value};`)
31
+ .join('\n');
32
+
33
+ /*
34
+ * What a document in no theme wears.
35
+ *
36
+ * `auto` is not a theme, it is the absence of a choice, and a design whose
37
+ * every block keys on data-theme paints nothing at all when nobody has
38
+ * chosen. So the first theme declared is also written at the bare root, and
39
+ * where a design has both light and dark the system preference picks between
40
+ * them for a reader who has told their system and not this page.
41
+ *
42
+ * The dark block is guarded against an explicit light, or a reader who chose
43
+ * light on a dark-preferring system would be overruled by their own machine.
44
+ */
45
+ function systemBlocks(livery: Livery): string[] {
46
+ const themes = Object.entries(livery.themes);
47
+ const [firstName, first] = themes[0];
48
+ const out = [
49
+ `:root {\n color-scheme: ${first.colorScheme};\n\n${declare(first.slots, ' ')}\n}`,
50
+ ];
51
+ const other = themes.find(([name]) => name !== firstName && name === 'dark') ??
52
+ themes.find(([name]) => name !== firstName);
53
+ if (themes.length > 1 && other && first.colorScheme !== other[1].colorScheme) {
54
+ const [, o] = other;
55
+ out.push(
56
+ `@media (prefers-color-scheme: ${o.colorScheme}) {\n` +
57
+ ` :root:not([data-theme='${firstName}']) {\n` +
58
+ ` color-scheme: ${o.colorScheme};\n\n${declare(o.slots, ' ')}\n }\n}`,
59
+ );
60
+ }
61
+ return out;
62
+ }
63
+
28
64
  // Throws on the first theme that leaves a required slot undefined or sets one
29
65
  // that belongs to the kit. A design either paints a whole face or it does not
30
66
  // compile.
@@ -41,18 +77,17 @@ export function renderTokensCss(livery: Livery): string {
41
77
  `${theme}: structure belongs to the kit, and livery.json sets:\n ${forbidden.join('\n ')}`,
42
78
  );
43
79
  }
44
- const declared = Object.entries(slots)
45
- .map(([slot, value]) => ` ${slot}: ${value};`)
46
- .join('\n');
47
80
  blocks.push(
48
- `:root[data-theme='${theme}'] {\n color-scheme: ${colorScheme};\n\n${declared}\n}`,
81
+ `:root[data-theme='${theme}'] {\n color-scheme: ${colorScheme};\n\n${declare(slots, ' ')}\n}`,
49
82
  );
50
83
  }
51
84
  return (
52
85
  `/*\n * Generated from livery.json. Never edit: run nv-livery.\n *\n` +
53
86
  ` * The ${livery.name} palette, colour and shadow only, worn by whichever\n` +
54
87
  ` * 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`
88
+ ` * over the kit's layered defaults without a specificity trick.\n *\n` +
89
+ ` * The first block is what a document in no theme wears, since auto is\n` +
90
+ ` * the absence of a choice and not a theme of its own.\n */\n\n` +
91
+ `${[...systemBlocks(livery), ...blocks].join('\n\n')}\n`
57
92
  );
58
93
  }