@johnmorrisdotca/kazoku-ui 0.2.1 → 0.7.2

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 (63) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +100 -6
  3. package/dist/address-state/address.d.ts +76 -0
  4. package/dist/address-state/address.js +171 -0
  5. package/dist/address-state/index.d.ts +1 -0
  6. package/dist/address-state/index.js +2 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/language-picker/define.d.ts +1 -0
  10. package/dist/language-picker/define.js +9 -0
  11. package/dist/language-picker/element.d.ts +39 -0
  12. package/dist/language-picker/element.js +194 -0
  13. package/dist/language-picker/index.d.ts +4 -0
  14. package/dist/language-picker/index.js +5 -0
  15. package/dist/language-picker/languages.d.ts +38 -0
  16. package/dist/language-picker/languages.js +100 -0
  17. package/dist/language-picker/styles.d.ts +1 -0
  18. package/dist/language-picker/styles.js +3 -0
  19. package/dist/language-picker/words.d.ts +8 -0
  20. package/dist/language-picker/words.js +7 -0
  21. package/dist/save-image/define.d.ts +1 -0
  22. package/dist/save-image/define.js +9 -0
  23. package/dist/save-image/element.d.ts +39 -0
  24. package/dist/save-image/element.js +186 -0
  25. package/dist/save-image/format.d.ts +20 -0
  26. package/dist/save-image/format.js +37 -0
  27. package/dist/save-image/index.d.ts +6 -0
  28. package/dist/save-image/index.js +8 -0
  29. package/dist/save-image/render.d.ts +55 -0
  30. package/dist/save-image/render.js +188 -0
  31. package/dist/save-image/styles.d.ts +1 -0
  32. package/dist/save-image/styles.js +9 -0
  33. package/dist/save-image/words.d.ts +12 -0
  34. package/dist/save-image/words.js +7 -0
  35. package/dist/shared/adopt.d.ts +4 -0
  36. package/dist/shared/adopt.js +34 -0
  37. package/dist/shared/base.d.ts +1 -0
  38. package/dist/shared/base.js +40 -0
  39. package/dist/shared/scheme.d.ts +1 -0
  40. package/dist/shared/scheme.js +19 -0
  41. package/dist/shared/store.d.ts +2 -0
  42. package/dist/shared/store.js +17 -0
  43. package/dist/shared/target.d.ts +2 -0
  44. package/dist/shared/target.js +11 -0
  45. package/dist/theme-switch/define.d.ts +1 -0
  46. package/dist/theme-switch/define.js +9 -0
  47. package/dist/theme-switch/element.d.ts +39 -0
  48. package/dist/theme-switch/element.js +203 -0
  49. package/dist/theme-switch/index.d.ts +4 -0
  50. package/dist/theme-switch/index.js +5 -0
  51. package/dist/theme-switch/styles.d.ts +1 -0
  52. package/dist/theme-switch/styles.js +8 -0
  53. package/dist/theme-switch/theme.d.ts +29 -0
  54. package/dist/theme-switch/theme.js +43 -0
  55. package/dist/theme-switch/words.d.ts +11 -0
  56. package/dist/theme-switch/words.js +7 -0
  57. package/dist/view-controls/controller.d.ts +22 -2
  58. package/dist/view-controls/controller.js +50 -9
  59. package/dist/view-controls/element.d.ts +7 -2
  60. package/dist/view-controls/element.js +84 -26
  61. package/dist/view-controls/math.d.ts +2 -2
  62. package/dist/view-controls/math.js +4 -3
  63. package/package.json +67 -4
@@ -0,0 +1,37 @@
1
+ // What saving needs that is not the DOM: the formats, the file name and the size of the picture. Nothing here touches the DOM.
2
+ export const FORMATS = ["png", "svg"];
3
+ export const MIME = { png: "image/png", svg: "image/svg+xml" };
4
+ /** The most a picture may be on one side, and in all: past these a canvas fails in some browsers, so the scale is lowered instead. */
5
+ export const MAX_SIDE = 8192;
6
+ export const MAX_PIXELS = 36_000_000;
7
+ /** The formats of `formats="png svg"`: the known ones, once each, in the order given. Nothing usable gives `png` and `svg`. */
8
+ export function parseFormats(value) {
9
+ const found = [];
10
+ for (const word of (value ?? "").toLowerCase().split(/[\s,]+/))
11
+ if (FORMATS.includes(word) && !found.includes(word))
12
+ found.push(word);
13
+ return found.length > 0 ? found : [...FORMATS];
14
+ }
15
+ /** Which formats a kind of target can give: an SVG both, a canvas and any other element a PNG. */
16
+ export const formatsFor = (kind) => (kind === "svg" ? FORMATS : ["png"]);
17
+ /** A file name that is safe on any system, with the format's extension: no path, no control or reserved characters, at most 80 characters of name. */
18
+ export function filenameFor(name, format) {
19
+ const base = (name ?? "")
20
+ .normalize("NFC")
21
+ .replace(/\.(png|svg)$/i, "")
22
+ .replace(/[\\/:*?"<>|]+/g, "-")
23
+ .replace(/\p{Cc}+/gu, "-")
24
+ .replace(/\s+/g, " ")
25
+ .replace(/^[\s.-]+|[\s.-]+$/g, "")
26
+ .slice(0, 80)
27
+ .trim();
28
+ return `${base === "" ? "image" : base}.${format}`;
29
+ }
30
+ /** The pixels of a PNG of a target `width` by `height` CSS pixels at `scale`: whole pixels, and the scale lowered so that no side passes MAX_SIDE and the whole stays within MAX_PIXELS. */
31
+ export function pixelSize(width, height, scale) {
32
+ const w = Math.max(1, width);
33
+ const h = Math.max(1, height);
34
+ let s = Number.isFinite(scale) && scale > 0 ? scale : 1;
35
+ s = Math.min(s, MAX_SIDE / w, MAX_SIDE / h, Math.sqrt(MAX_PIXELS / (w * h)));
36
+ return { width: Math.max(1, Math.round(w * s)), height: Math.max(1, Math.round(h * s)), scale: s };
37
+ }
@@ -0,0 +1,6 @@
1
+ export { filenameFor, FORMATS, formatsFor, MAX_PIXELS, MAX_SIDE, MIME, parseFormats, pixelSize, type Format, type TargetKind } from "./format.ts";
2
+ export { downloadBlob, kindOf, renderImage, saveImage, serializeElement, serializeSvg, sizeOf, svgToPng, type Picture, type RenderImageOptions, type RenderOptions } from "./render.ts";
3
+ export { languageOf, WORDS, wordsFor, type Language, type SaveWords } from "./words.ts";
4
+ export declare const EVENT = "kazoku-save";
5
+ export declare const ERROR_EVENT = "kazoku-saveerror";
6
+ export declare const TAG = "kazoku-save-image";
@@ -0,0 +1,8 @@
1
+ // The save-image part without the element: the formats, the file name, the size, and the functions that turn a canvas, an SVG or an element into a picture and download it.
2
+ // Nothing here touches the DOM until a function is called, so it imports on a server.
3
+ export { filenameFor, FORMATS, formatsFor, MAX_PIXELS, MAX_SIDE, MIME, parseFormats, pixelSize } from "./format.js";
4
+ export { downloadBlob, kindOf, renderImage, saveImage, serializeElement, serializeSvg, sizeOf, svgToPng } from "./render.js";
5
+ export { languageOf, WORDS, wordsFor } from "./words.js";
6
+ export const EVENT = "kazoku-save";
7
+ export const ERROR_EVENT = "kazoku-saveerror";
8
+ export const TAG = "kazoku-save-image";
@@ -0,0 +1,55 @@
1
+ import { type Format, type TargetKind } from "./format.ts";
2
+ /** What a target is. */
3
+ export declare function kindOf(target: Element): TargetKind;
4
+ /** The target's size in CSS pixels: a canvas' own, else what is drawn on the page, else an SVG's `viewBox`. */
5
+ export declare function sizeOf(target: Element): {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ export interface RenderOptions {
10
+ /** Write the computed styles into what is saved, so it looks the same away from the page's stylesheets. On by default. */
11
+ inlineStyles?: boolean;
12
+ }
13
+ /** An SVG as text that stands alone: the namespaces, a size, a `viewBox`, and (unless `inlineStyles` is false) the styles it is drawn in. */
14
+ export declare function serializeSvg(svg: Element, options?: RenderOptions): {
15
+ text: string;
16
+ width: number;
17
+ height: number;
18
+ };
19
+ /** Any element as an SVG that draws it (a `foreignObject`), the styles written in. */
20
+ export declare function serializeElement(element: Element, options?: RenderOptions): {
21
+ text: string;
22
+ width: number;
23
+ height: number;
24
+ };
25
+ /** SVG text as a PNG `scale` times its size. A browser that will not draw it (or whose canvas would be tainted) rejects. */
26
+ export declare function svgToPng(text: string, width: number, height: number, scale: number, background?: string): Promise<{
27
+ blob: Blob;
28
+ width: number;
29
+ height: number;
30
+ }>;
31
+ export interface RenderImageOptions extends RenderOptions {
32
+ format: Format;
33
+ /** PNG pixels for each CSS pixel of an SVG or element. 2 by default. A canvas is saved at its own size. */
34
+ scale?: number;
35
+ /** A CSS colour painted behind a PNG; the PNG is transparent without it. */
36
+ background?: string;
37
+ }
38
+ /** What a render makes. */
39
+ export interface Picture {
40
+ blob: Blob;
41
+ format: Format;
42
+ /** The picture's size: pixels for a PNG, CSS pixels for an SVG. */
43
+ width: number;
44
+ height: number;
45
+ }
46
+ /** The target as a picture. Rejects for a format the target cannot give (an SVG of a canvas) and when the browser cannot draw it. */
47
+ export declare function renderImage(target: Element, options: RenderImageOptions): Promise<Picture>;
48
+ /** Hands the blob to the browser as a download of that name. */
49
+ export declare function downloadBlob(blob: Blob, filename: string): void;
50
+ /** Renders the target and downloads it as `name` with the format's extension. */
51
+ export declare function saveImage(target: Element, options: RenderImageOptions & {
52
+ name?: string;
53
+ }): Promise<Picture & {
54
+ filename: string;
55
+ }>;
@@ -0,0 +1,188 @@
1
+ // Turns a target into a picture: a canvas, an SVG or any element, as a PNG, and an SVG as an SVG. Nothing here touches the DOM until a function is called.
2
+ //
3
+ // A canvas is saved at its own pixel size. An SVG is serialised with the styles it is drawn in written into it, so it looks the same on its own. Any other element is drawn
4
+ // through an SVG `foreignObject` with every computed style written in: that carries layout, colours, borders, text and inline SVG, and draws a canvas inside it as a picture.
5
+ // What cannot come with it: web fonts (a picture made this way has only the system's fonts), `::before` and `::after`, and images that are not `data:` URLs (an <img> whose
6
+ // pixels this page may read is converted; a cross-origin one is left out).
7
+ import { filenameFor, MIME, pixelSize } from "./format.js";
8
+ const SVG_NS = "http://www.w3.org/2000/svg";
9
+ const XHTML_NS = "http://www.w3.org/1999/xhtml";
10
+ /** What a target is. */
11
+ export function kindOf(target) {
12
+ if (target.localName === "canvas")
13
+ return "canvas";
14
+ return target.localName === "svg" && target.namespaceURI === SVG_NS ? "svg" : "element";
15
+ }
16
+ /** The target's size in CSS pixels: a canvas' own, else what is drawn on the page, else an SVG's `viewBox`. */
17
+ export function sizeOf(target) {
18
+ if (target.localName === "canvas")
19
+ return { width: target.width, height: target.height };
20
+ const box = target.getBoundingClientRect();
21
+ if (box.width > 0 && box.height > 0)
22
+ return { width: Math.ceil(box.width), height: Math.ceil(box.height) };
23
+ const view = (target.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/).map(Number);
24
+ if (view.length === 4 && view[2] > 0 && view[3] > 0)
25
+ return { width: view[2], height: view[3] };
26
+ return { width: Math.ceil(Number(target.getAttribute("width")) || 300), height: Math.ceil(Number(target.getAttribute("height")) || 150) };
27
+ }
28
+ /** The properties of an SVG element that its stylesheet can decide, and so must travel with it. */
29
+ const SVG_PROPERTIES = [
30
+ "fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset",
31
+ "opacity", "color", "font-family", "font-size", "font-weight", "font-style", "letter-spacing", "text-anchor", "dominant-baseline", "visibility", "display", "paint-order", "stop-color", "stop-opacity", "clip-rule", "mix-blend-mode",
32
+ ];
33
+ function inline(source, copy, properties) {
34
+ const style = (source.ownerDocument.defaultView ?? globalThis).getComputedStyle(source);
35
+ const target = copy.style;
36
+ // `corner-*-shape` is left out: it is a default, and a browser that writes it back as the `corner` shorthand loses the radius that the other properties carry.
37
+ for (const name of properties ?? Array.from(style).filter((one) => !one.startsWith("corner-")))
38
+ target.setProperty(name, style.getPropertyValue(name));
39
+ }
40
+ function walk(source, copy, visit) {
41
+ visit(source, copy);
42
+ for (let i = 0; i < source.children.length; i += 1) {
43
+ const child = copy.children[i];
44
+ if (child !== undefined)
45
+ walk(source.children[i], child, visit);
46
+ }
47
+ }
48
+ /** An SVG as text that stands alone: the namespaces, a size, a `viewBox`, and (unless `inlineStyles` is false) the styles it is drawn in. */
49
+ export function serializeSvg(svg, options = {}) {
50
+ const { width, height } = sizeOf(svg);
51
+ const copy = svg.cloneNode(true);
52
+ if (options.inlineStyles !== false)
53
+ walk(svg, copy, (from, to) => inline(from, to, SVG_PROPERTIES));
54
+ copy.setAttribute("xmlns", SVG_NS);
55
+ if (!copy.hasAttribute("xmlns:xlink") && /xlink:/.test(svg.outerHTML))
56
+ copy.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
57
+ // The size written is the size it is drawn at. Without a viewBox the size it was authored at becomes the viewBox, so a scaled drawing is not cropped.
58
+ if (!copy.hasAttribute("viewBox")) {
59
+ const own = (name) => (/^\s*[\d.]+(px)?\s*$/.test(svg.getAttribute(name) ?? "") ? parseFloat(svg.getAttribute(name)) : undefined);
60
+ copy.setAttribute("viewBox", `0 0 ${own("width") ?? width} ${own("height") ?? height}`);
61
+ }
62
+ copy.setAttribute("width", String(width));
63
+ copy.setAttribute("height", String(height));
64
+ return { text: new globalThis.XMLSerializer().serializeToString(copy), width, height };
65
+ }
66
+ /** Any element as an SVG that draws it (a `foreignObject`), the styles written in. */
67
+ export function serializeElement(element, options = {}) {
68
+ const { width, height } = sizeOf(element);
69
+ const copy = element.cloneNode(true);
70
+ walk(element, copy, (from, to) => {
71
+ if (options.inlineStyles !== false || from === element)
72
+ inline(from, to, undefined);
73
+ if (from.localName === "canvas") {
74
+ const picture = from.ownerDocument.createElement("img");
75
+ try {
76
+ picture.setAttribute("src", from.toDataURL("image/png"));
77
+ }
78
+ catch {
79
+ /* a canvas that may not be read: left empty */
80
+ }
81
+ picture.style.width = to.style.width;
82
+ picture.style.height = to.style.height;
83
+ to.replaceWith(picture);
84
+ }
85
+ else if (from.localName === "img") {
86
+ const data = imageData(from);
87
+ if (data !== undefined)
88
+ to.setAttribute("src", data);
89
+ to.removeAttribute("srcset");
90
+ }
91
+ });
92
+ copy.style.margin = "0";
93
+ copy.style.position = "static";
94
+ copy.style.width = `${width}px`;
95
+ copy.style.height = `${height}px`;
96
+ copy.style.boxSizing = "border-box";
97
+ const html = new globalThis.XMLSerializer().serializeToString(copy);
98
+ const text = `<svg xmlns="${SVG_NS}" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><foreignObject x="0" y="0" width="100%" height="100%">${html.includes("xmlns=") ? html : html.replace(/^<([a-zA-Z][^\s/>]*)/, `<$1 xmlns="${XHTML_NS}"`)}</foreignObject></svg>`;
99
+ return { text, width, height };
100
+ }
101
+ /** An <img>'s pixels as a `data:` URL, when this page may read them (a `data:` source already is). */
102
+ function imageData(image) {
103
+ if (image.src.startsWith("data:"))
104
+ return image.src;
105
+ if (!image.complete || image.naturalWidth === 0)
106
+ return undefined;
107
+ try {
108
+ const canvas = image.ownerDocument.createElement("canvas");
109
+ canvas.width = image.naturalWidth;
110
+ canvas.height = image.naturalHeight;
111
+ canvas.getContext("2d")?.drawImage(image, 0, 0);
112
+ return canvas.toDataURL("image/png");
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ function toBlob(canvas) {
119
+ return new Promise((done, fail) => canvas.toBlob((blob) => (blob === null ? fail(new Error("the picture could not be made")) : done(blob)), MIME.png));
120
+ }
121
+ function paint(width, height, background) {
122
+ const canvas = document.createElement("canvas");
123
+ canvas.width = width;
124
+ canvas.height = height;
125
+ const context = canvas.getContext("2d");
126
+ if (context === null)
127
+ throw new Error("a canvas is not available");
128
+ if (background !== undefined && background !== "") {
129
+ context.fillStyle = background;
130
+ context.fillRect(0, 0, width, height);
131
+ }
132
+ return { canvas, context };
133
+ }
134
+ /** SVG text as a PNG `scale` times its size. A browser that will not draw it (or whose canvas would be tainted) rejects. */
135
+ export async function svgToPng(text, width, height, scale, background) {
136
+ const pixels = pixelSize(width, height, scale);
137
+ const image = new globalThis.Image();
138
+ const loaded = new Promise((done, fail) => {
139
+ image.onload = () => done();
140
+ image.onerror = () => fail(new Error("the picture could not be drawn"));
141
+ });
142
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(text)}`;
143
+ await loaded;
144
+ const { canvas, context } = paint(pixels.width, pixels.height, background);
145
+ context.drawImage(image, 0, 0, pixels.width, pixels.height);
146
+ return { blob: await toBlob(canvas), width: pixels.width, height: pixels.height };
147
+ }
148
+ /** The target as a picture. Rejects for a format the target cannot give (an SVG of a canvas) and when the browser cannot draw it. */
149
+ export async function renderImage(target, options) {
150
+ const kind = kindOf(target);
151
+ const { format } = options;
152
+ if (format === "svg") {
153
+ if (kind !== "svg")
154
+ throw new Error("only an SVG can be saved as SVG");
155
+ const { text, width, height } = serializeSvg(target, options);
156
+ return { blob: new Blob([text], { type: MIME.svg }), format, width, height };
157
+ }
158
+ if (kind === "canvas") {
159
+ const source = target;
160
+ if (options.background === undefined || options.background === "")
161
+ return { blob: await toBlob(source), format, width: source.width, height: source.height };
162
+ const { canvas, context } = paint(source.width, source.height, options.background);
163
+ context.drawImage(source, 0, 0);
164
+ return { blob: await toBlob(canvas), format, width: source.width, height: source.height };
165
+ }
166
+ const made = kind === "svg" ? serializeSvg(target, options) : serializeElement(target, options);
167
+ const png = await svgToPng(made.text, made.width, made.height, options.scale ?? 2, options.background);
168
+ return { blob: png.blob, format, width: png.width, height: png.height };
169
+ }
170
+ /** Hands the blob to the browser as a download of that name. */
171
+ export function downloadBlob(blob, filename) {
172
+ const link = document.createElement("a");
173
+ const url = URL.createObjectURL(blob);
174
+ link.href = url;
175
+ link.download = filename;
176
+ link.rel = "noopener";
177
+ document.body.append(link);
178
+ link.click();
179
+ link.remove();
180
+ globalThis.setTimeout(() => URL.revokeObjectURL(url), 4000);
181
+ }
182
+ /** Renders the target and downloads it as `name` with the format's extension. */
183
+ export async function saveImage(target, options) {
184
+ const picture = await renderImage(target, options);
185
+ const filename = filenameFor(options.name, options.format);
186
+ downloadBlob(picture.blob, filename);
187
+ return { ...picture, filename };
188
+ }
@@ -0,0 +1 @@
1
+ export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: inline-block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"]):not([data-scheme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]), :host([data-scheme=\"dark\"]:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\n\nbutton[aria-busy=\"true\"] { opacity: 0.6; cursor: progress; }\n@media (max-width: 640px) {\n .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n button { padding: 0; }\n}\n";
@@ -0,0 +1,9 @@
1
+ import { BASE_STYLES } from "../shared/base.js";
2
+ // A button for each format, with a download arrow. On a phone the words are left to a screen reader.
3
+ export const STYLES = `${BASE_STYLES}
4
+ button[aria-busy="true"] { opacity: 0.6; cursor: progress; }
5
+ @media (max-width: 640px) {
6
+ .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
7
+ button { padding: 0; }
8
+ }
9
+ `;
@@ -0,0 +1,12 @@
1
+ export interface SaveWords {
2
+ group: string;
3
+ png: string;
4
+ svg: string;
5
+ saved: string;
6
+ failed: string;
7
+ }
8
+ export type Language = "en" | "ja";
9
+ export declare const WORDS: Record<Language, SaveWords>;
10
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
11
+ export declare const languageOf: (lang: string | null | undefined) => Language;
12
+ export declare const wordsFor: (language: Language, overrides?: Partial<SaveWords>) => SaveWords;
@@ -0,0 +1,7 @@
1
+ export const WORDS = {
2
+ en: { group: "Save image", png: "Save PNG", svg: "Save SVG", saved: "Saved", failed: "Could not save" },
3
+ ja: { group: "画像を保存", png: "PNGで保存", svg: "SVGで保存", saved: "保存しました", failed: "保存できませんでした" },
4
+ };
5
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
6
+ export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
7
+ export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
@@ -0,0 +1,4 @@
1
+ /** True when this browser can adopt a constructable stylesheet into a shadow root. */
2
+ export declare function canAdopt(root: ShadowRoot): boolean;
3
+ /** Adds `css` to `root`: as an adopted stylesheet where there is one (one sheet, shared by every element with the same text), else as a `<style>` carrying `nonce`. */
4
+ export declare function adoptStyles(root: ShadowRoot, css: string, nonce?: string): void;
@@ -0,0 +1,34 @@
1
+ // Puts an element's styles into its shadow root in a way a strict Content-Security-Policy allows.
2
+ //
3
+ // A `<style>` written into a shadow root is inline style, which `style-src 'self'` blocks. A constructable stylesheet handed to `adoptedStyleSheets` is not inline
4
+ // markup and needs no allowance, so it is used wherever the browser has it. Where it does not (older browsers), the same text goes into a `<style>` element, and a
5
+ // page with a strict `style-src` allows that one with a nonce: the element's `nonce` is copied onto the `<style>`.
6
+ const sheets = new Map();
7
+ /** True when this browser can adopt a constructable stylesheet into a shadow root. */
8
+ export function canAdopt(root) {
9
+ return typeof CSSStyleSheet === "function" && "replaceSync" in CSSStyleSheet.prototype && "adoptedStyleSheets" in root;
10
+ }
11
+ /** Adds `css` to `root`: as an adopted stylesheet where there is one (one sheet, shared by every element with the same text), else as a `<style>` carrying `nonce`. */
12
+ export function adoptStyles(root, css, nonce = "") {
13
+ if (canAdopt(root)) {
14
+ try {
15
+ let sheet = sheets.get(css);
16
+ if (sheet === undefined) {
17
+ sheet = new CSSStyleSheet();
18
+ sheet.replaceSync(css);
19
+ sheets.set(css, sheet);
20
+ }
21
+ if (!root.adoptedStyleSheets.includes(sheet))
22
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
23
+ return;
24
+ }
25
+ catch {
26
+ /* a browser that has the API and refuses it: the <style> below */
27
+ }
28
+ }
29
+ const style = (root.ownerDocument ?? document).createElement("style");
30
+ if (nonce !== "")
31
+ style.setAttribute("nonce", nonce);
32
+ style.textContent = css;
33
+ root.prepend(style);
34
+ }
@@ -0,0 +1 @@
1
+ export declare const BASE_STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: inline-block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"]):not([data-scheme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]), :host([data-scheme=\"dark\"]:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\n";
@@ -0,0 +1,40 @@
1
+ // What every part's styles start from: the colours as custom properties on the host (--kazoku-ink, --kazoku-surface, --kazoku-line, --kazoku-accent), light and dark
2
+ // (`data-scheme`, which `watchScheme` keeps, or `theme="light|dark"` set by the page), the 48 px button and the focus ring.
3
+ export const BASE_STYLES = `
4
+ :host {
5
+ --_ink: var(--kazoku-ink, #1b1b1f);
6
+ --_surface: var(--kazoku-surface, #ffffff);
7
+ --_line: var(--kazoku-line, #c9ccd3);
8
+ --_accent: var(--kazoku-accent, #2457d6);
9
+ display: inline-block;
10
+ box-sizing: border-box;
11
+ color: var(--_ink);
12
+ font: 600 0.9rem/1.2 system-ui, -apple-system, "Hiragino Sans", "Noto Sans JP", "Segoe UI", sans-serif;
13
+ color-scheme: light dark;
14
+ }
15
+ @media (prefers-color-scheme: dark) {
16
+ :host(:not([theme="light"]):not([data-scheme="light"])) {
17
+ --_ink: var(--kazoku-ink, #f1f2f6);
18
+ --_surface: var(--kazoku-surface, #1c1d22);
19
+ --_line: var(--kazoku-line, #4a4d58);
20
+ --_accent: var(--kazoku-accent, #8fb0ff);
21
+ }
22
+ }
23
+ :host([theme="dark"]), :host([data-scheme="dark"]:not([theme="light"])) {
24
+ --_ink: var(--kazoku-ink, #f1f2f6);
25
+ --_surface: var(--kazoku-surface, #1c1d22);
26
+ --_line: var(--kazoku-line, #4a4d58);
27
+ --_accent: var(--kazoku-accent, #8fb0ff);
28
+ color-scheme: dark;
29
+ }
30
+ :host([theme="light"]) { color-scheme: light; }
31
+ [hidden] { display: none !important; }
32
+ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
33
+ .group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }
34
+ button { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }
35
+ button:hover:not(:disabled) { border-color: var(--_ink); }
36
+ button:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }
37
+ button[aria-pressed="true"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }
38
+ button:disabled { opacity: 0.4; cursor: default; }
39
+ svg { width: 18px; height: 18px; fill: currentColor; flex: none; }
40
+ `;
@@ -0,0 +1 @@
1
+ export declare function watchScheme(host: HTMLElement): () => void;
@@ -0,0 +1,19 @@
1
+ // Keeps `data-scheme="light|dark"` on an element the same as the colours the page is in: the page's forced theme (`data-theme` on <html>) or else the device's.
2
+ // An element's styles read that attribute, so it is dark in a page that forced dark on a light device. It cannot use `:host-context`, which Firefox and Safari lack.
3
+ export function watchScheme(host) {
4
+ const root = host.ownerDocument.documentElement;
5
+ const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
6
+ const update = () => {
7
+ const forced = root.getAttribute("data-theme");
8
+ const dark = forced === "dark" || (forced !== "light" && media?.matches === true);
9
+ host.setAttribute("data-scheme", dark ? "dark" : "light");
10
+ };
11
+ update();
12
+ const observer = new MutationObserver(update);
13
+ observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] });
14
+ media?.addEventListener("change", update);
15
+ return () => {
16
+ observer.disconnect();
17
+ media?.removeEventListener("change", update);
18
+ };
19
+ }
@@ -0,0 +1,2 @@
1
+ export declare function localStore(): Storage | undefined;
2
+ export declare function remember(key: string, value: string): void;
@@ -0,0 +1,17 @@
1
+ // localStorage, for a choice a page wants to keep on the device. Every use is in a try/catch: a browser that keeps nothing (a private window, blocked site data, a sandboxed frame) is the same as an empty store.
2
+ export function localStore() {
3
+ try {
4
+ return globalThis.localStorage;
5
+ }
6
+ catch {
7
+ return undefined;
8
+ }
9
+ }
10
+ export function remember(key, value) {
11
+ try {
12
+ localStore()?.setItem(key, value);
13
+ }
14
+ catch {
15
+ /* not remembered; the choice still holds for this page */
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ /** The element a part writes to, from a CSS selector: found in the part's own tree first (so it works inside a shadow root), then in the document. No selector is the page's <html>. A selector that matches nothing, or is not one, is `undefined`. */
2
+ export declare function resolveTarget(host: HTMLElement, selector: string | null): Element | undefined;
@@ -0,0 +1,11 @@
1
+ /** The element a part writes to, from a CSS selector: found in the part's own tree first (so it works inside a shadow root), then in the document. No selector is the page's <html>. A selector that matches nothing, or is not one, is `undefined`. */
2
+ export function resolveTarget(host, selector) {
3
+ if (selector === null || selector === "")
4
+ return host.ownerDocument.documentElement;
5
+ try {
6
+ return host.getRootNode().querySelector(selector) ?? host.ownerDocument.querySelector(selector) ?? undefined;
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export declare function defineThemeSwitch(registry?: CustomElementRegistry): boolean;
@@ -0,0 +1,9 @@
1
+ // Importing this registers <kazoku-theme-switch>, unless the tag is already taken (the first definition wins; see view-controls/define.ts for why a taken tag is left alone).
2
+ import { TAG, ThemeSwitchElement } from "./element.js";
3
+ export function defineThemeSwitch(registry = customElements) {
4
+ if (registry.get(TAG) !== undefined)
5
+ return false;
6
+ registry.define(TAG, ThemeSwitchElement);
7
+ return true;
8
+ }
9
+ defineThemeSwitch();
@@ -0,0 +1,39 @@
1
+ import { EVENT, TAG } from "./index.ts";
2
+ import { type Scheme, type Theme } from "./theme.ts";
3
+ import { type ThemeWords } from "./words.ts";
4
+ export { EVENT, TAG };
5
+ /** What the `kazoku-themechange` event carries in `detail`. */
6
+ export interface ThemeChangeDetail {
7
+ /** What the reader chose: `light`, `dark` or `auto`. */
8
+ choice: Theme;
9
+ /** The colours now showing: `light` or `dark`. Auto is the device's. */
10
+ scheme: Scheme;
11
+ /** `init`, `user` (a button), `set` (the `value` property or attribute), `system` (the device changed under Auto) or `external` (something else wrote the target's `data-theme`). */
12
+ reason: "init" | "user" | "set" | "system" | "external";
13
+ /** The element the choice is written on. */
14
+ target: Element;
15
+ }
16
+ /**
17
+ * Attributes: `value` (`light`, `dark` or `auto`; the choice), `target` (a CSS selector for the element that gets `data-theme`; the page's <html> by default),
18
+ * `modes` (which buttons: `light dark` leaves Auto out), `address` (keep the choice in `?theme=`, or the key `address-key` names), `storage` (also remember it in
19
+ * localStorage under that key; a bare `storage` uses `kazoku-theme`), `lang` (`en` or `ja`), `theme` (`light` or `dark`: its own look, otherwise the page's).
20
+ * The choice is read, in this order, from the address, the store, `value`, then what the target already holds.
21
+ */
22
+ export declare class ThemeSwitchElement extends HTMLElement {
23
+ #private;
24
+ static observedAttributes: string[];
25
+ constructor();
26
+ /** The choice now: `light`, `dark` or `auto`. Setting it applies it (event reason `set`). */
27
+ get value(): Theme;
28
+ set value(next: string);
29
+ /** The colours showing now: `light` or `dark`. */
30
+ get scheme(): Scheme;
31
+ /** The element the choice is written on. */
32
+ get target(): Element | undefined;
33
+ set target(element: Element | undefined);
34
+ /** Your own labels over the language's, for example `{ auto: "System" }`. */
35
+ set words(words: Partial<ThemeWords>);
36
+ connectedCallback(): void;
37
+ disconnectedCallback(): void;
38
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
39
+ }