@toyonix/web-kit 0.0.0-stage → 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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/config/eslint.js +68 -0
  4. package/config/prettier.js +20 -0
  5. package/config/tsconfig.base.json +22 -0
  6. package/dist/css/base.css +58 -0
  7. package/dist/css/components.css +915 -0
  8. package/dist/css/fonts/LICENSE-Newsreader.txt +93 -0
  9. package/dist/css/fonts/LICENSE-Space-Grotesk.txt +93 -0
  10. package/dist/css/fonts/LICENSE-Space-Mono.txt +93 -0
  11. package/dist/css/fonts/newsreader-latin-ext-opsz-italic.woff2 +0 -0
  12. package/dist/css/fonts/newsreader-latin-ext-opsz-normal.woff2 +0 -0
  13. package/dist/css/fonts/newsreader-latin-opsz-italic.woff2 +0 -0
  14. package/dist/css/fonts/newsreader-latin-opsz-normal.woff2 +0 -0
  15. package/dist/css/fonts/space-grotesk-latin-400-normal.woff2 +0 -0
  16. package/dist/css/fonts/space-grotesk-latin-500-normal.woff2 +0 -0
  17. package/dist/css/fonts/space-grotesk-latin-700-normal.woff2 +0 -0
  18. package/dist/css/fonts/space-grotesk-latin-ext-400-normal.woff2 +0 -0
  19. package/dist/css/fonts/space-grotesk-latin-ext-500-normal.woff2 +0 -0
  20. package/dist/css/fonts/space-grotesk-latin-ext-700-normal.woff2 +0 -0
  21. package/dist/css/fonts/space-mono-latin-400-normal.woff2 +0 -0
  22. package/dist/css/fonts/space-mono-latin-700-normal.woff2 +0 -0
  23. package/dist/css/fonts/space-mono-latin-ext-400-normal.woff2 +0 -0
  24. package/dist/css/fonts/space-mono-latin-ext-700-normal.woff2 +0 -0
  25. package/dist/css/fonts.css +141 -0
  26. package/dist/css/index.css +10 -0
  27. package/dist/css/tailwind.css +46 -0
  28. package/dist/css/tokens.css +66 -0
  29. package/dist/env.d.ts +28 -0
  30. package/dist/env.js +62 -0
  31. package/dist/index.d.ts +4 -0
  32. package/dist/index.js +4 -0
  33. package/dist/next/index.d.ts +36 -0
  34. package/dist/next/index.js +103 -0
  35. package/dist/react/Alert.d.ts +8 -0
  36. package/dist/react/Alert.js +16 -0
  37. package/dist/react/AppLink.d.ts +11 -0
  38. package/dist/react/AppLink.js +12 -0
  39. package/dist/react/AppShell.d.ts +20 -0
  40. package/dist/react/AppShell.js +94 -0
  41. package/dist/react/Badge.d.ts +10 -0
  42. package/dist/react/Badge.js +29 -0
  43. package/dist/react/Button.d.ts +16 -0
  44. package/dist/react/Button.js +36 -0
  45. package/dist/react/Card.d.ts +21 -0
  46. package/dist/react/Card.js +61 -0
  47. package/dist/react/CodeBlock.d.ts +8 -0
  48. package/dist/react/CodeBlock.js +16 -0
  49. package/dist/react/Dialog.d.ts +16 -0
  50. package/dist/react/Dialog.js +33 -0
  51. package/dist/react/DropdownMenu.d.ts +12 -0
  52. package/dist/react/DropdownMenu.js +36 -0
  53. package/dist/react/Table.d.ts +5 -0
  54. package/dist/react/Table.js +15 -0
  55. package/dist/react/Tabs.d.ts +9 -0
  56. package/dist/react/Tabs.js +26 -0
  57. package/dist/react/ThemeScript.d.ts +9 -0
  58. package/dist/react/ThemeScript.js +15 -0
  59. package/dist/react/ThemeToggle.d.ts +5 -0
  60. package/dist/react/ThemeToggle.js +22 -0
  61. package/dist/react/Tooltip.d.ts +13 -0
  62. package/dist/react/Tooltip.js +21 -0
  63. package/dist/react/cx.d.ts +4 -0
  64. package/dist/react/cx.js +7 -0
  65. package/dist/react/form.d.ts +19 -0
  66. package/dist/react/form.js +76 -0
  67. package/dist/react/index.d.ts +21 -0
  68. package/dist/react/index.js +21 -0
  69. package/dist/react/layout.d.ts +7 -0
  70. package/dist/react/layout.js +29 -0
  71. package/dist/react/typography.d.ts +23 -0
  72. package/dist/react/typography.js +46 -0
  73. package/dist/react/use-registry.d.ts +9 -0
  74. package/dist/react/use-registry.js +41 -0
  75. package/dist/react/use-theme.d.ts +18 -0
  76. package/dist/react/use-theme.js +46 -0
  77. package/dist/registry/build.d.ts +28 -0
  78. package/dist/registry/build.js +91 -0
  79. package/dist/registry/index.d.ts +49 -0
  80. package/dist/registry/index.js +84 -0
  81. package/dist/slugs.d.ts +15 -0
  82. package/dist/slugs.js +37 -0
  83. package/dist/storage/index.d.ts +39 -0
  84. package/dist/storage/index.js +123 -0
  85. package/dist/theme.d.ts +19 -0
  86. package/dist/theme.js +26 -0
  87. package/package.json +111 -4
@@ -0,0 +1,91 @@
1
+ import { isReservedSlug, isRootRepo, isValidSlug } from "../slugs.js";
2
+ import "./index.js";
3
+ //#region src/registry/build.ts
4
+ /**
5
+ * Generates the registry from GitHub (spec §7). Used only by the root site at build time, e.g.
6
+ * in app/registry.json/route.ts with `export const dynamic = "force-static"`.
7
+ *
8
+ * Source of truth: public, non-archived repos owned by `owner` with the `topic` topic. Per-app
9
+ * metadata comes from the optional "toyonix" field in each repo's package.json.
10
+ */
11
+ const SITE_TOPIC = "toyonix-site";
12
+ var RegistryBuildError = class extends Error {
13
+ name = "RegistryBuildError";
14
+ };
15
+ function titleCase(slug) {
16
+ return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
17
+ }
18
+ async function buildRegistry(options) {
19
+ const { owner, topic = SITE_TOPIC, token, now = /* @__PURE__ */ new Date() } = options;
20
+ const doFetch = options.fetch ?? fetch;
21
+ const headers = {
22
+ accept: "application/vnd.github+json",
23
+ "x-github-api-version": "2022-11-28",
24
+ ...token ? { authorization: `Bearer ${token}` } : {}
25
+ };
26
+ const repos = [];
27
+ for (let page = 1;; page++) {
28
+ const response = await doFetch(`https://api.github.com/search/repositories?q=${encodeURIComponent(`topic:${topic} user:${owner}`)}&per_page=100&page=${page}`, { headers });
29
+ if (!response.ok) throw new RegistryBuildError(`GitHub search failed: ${response.status} ${response.statusText}`);
30
+ const body = await response.json();
31
+ repos.push(...body.items);
32
+ if (body.items.length < 100 || repos.length >= body.total_count) break;
33
+ }
34
+ const sites = repos.filter((repo) => !repo.archived && !repo.fork && !repo.private && !isRootRepo(repo.name));
35
+ const problems = [];
36
+ for (const repo of sites) {
37
+ if (!isValidSlug(repo.name)) problems.push(`"${repo.name}" is not a lowercase-kebab slug`);
38
+ if (isReservedSlug(repo.name)) problems.push(`"${repo.name}" is a reserved slug`);
39
+ }
40
+ const lower = sites.map((repo) => repo.name.toLowerCase());
41
+ for (const [i, name] of lower.entries()) if (lower.indexOf(name) !== i) problems.push(`"${name}" appears more than once`);
42
+ if (problems.length) throw new RegistryBuildError(`Invalid site repos:\n- ${problems.join("\n- ")}`);
43
+ const apps = await Promise.all(sites.map(async (repo) => {
44
+ const meta = await readSiteMetadata(doFetch, headers, owner, repo);
45
+ return {
46
+ id: repo.name,
47
+ title: meta.title ?? titleCase(repo.name),
48
+ description: meta.description ?? repo.description ?? "",
49
+ path: `/${repo.name}/`,
50
+ status: meta.status ?? "live",
51
+ nav: meta.nav ?? true,
52
+ order: meta.order ?? 100
53
+ };
54
+ }));
55
+ apps.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
56
+ return {
57
+ version: 1,
58
+ generatedAt: now.toISOString(),
59
+ apps
60
+ };
61
+ }
62
+ async function readSiteMetadata(doFetch, headers, owner, repo) {
63
+ const response = await doFetch(`https://api.github.com/repos/${owner}/${repo.name}/contents/package.json?ref=${encodeURIComponent(repo.default_branch)}`, { headers: {
64
+ ...headers,
65
+ accept: "application/vnd.github.raw+json"
66
+ } });
67
+ if (response.status === 404) return {};
68
+ if (!response.ok) throw new RegistryBuildError(`Reading ${repo.name}/package.json failed: ${response.status}`);
69
+ let pkg;
70
+ try {
71
+ pkg = await response.json();
72
+ } catch {
73
+ throw new RegistryBuildError(`${repo.name}/package.json is not valid JSON`);
74
+ }
75
+ const raw = pkg.toyonix;
76
+ if (typeof raw !== "object" || raw === null) return {};
77
+ const meta = raw;
78
+ return {
79
+ title: typeof meta.title === "string" ? meta.title : void 0,
80
+ description: typeof meta.description === "string" ? meta.description : void 0,
81
+ nav: typeof meta.nav === "boolean" ? meta.nav : void 0,
82
+ order: typeof meta.order === "number" ? meta.order : void 0,
83
+ status: [
84
+ "live",
85
+ "beta",
86
+ "archived"
87
+ ].includes(meta.status) ? meta.status : void 0
88
+ };
89
+ }
90
+ //#endregion
91
+ export { RegistryBuildError, SITE_TOPIC, buildRegistry };
@@ -0,0 +1,49 @@
1
+ //#region src/registry/index.d.ts
2
+ /**
3
+ * The app registry (spec §7): the list of toyonix apps, published by the root site at
4
+ * /registry.json and used for cross-app navigation. The schema is a frozen contract: an
5
+ * incompatible change is a major release and needs a new `version`.
6
+ */
7
+ export declare const REGISTRY_VERSION = 1;
8
+ /** Absolute URL of the production registry. Used at build time and in local dev. */
9
+ export declare const REGISTRY_URL = "https://toyonix.github.io/registry.json";
10
+ /** Path of the registry on the current origin (root-relative, NOT basePath-relative). */
11
+ export declare const REGISTRY_PATH = "/registry.json";
12
+ export type AppStatus = "live" | "beta" | "archived";
13
+ export interface RegistryApp {
14
+ /** Repo name = URL slug. */
15
+ id: string;
16
+ title: string;
17
+ description: string;
18
+ /** "/<id>/" */
19
+ path: string;
20
+ status: AppStatus;
21
+ /** Whether the app appears in the shared nav. */
22
+ nav: boolean;
23
+ /** Nav sort key, ascending. */
24
+ order: number;
25
+ }
26
+ export interface Registry {
27
+ version: typeof REGISTRY_VERSION;
28
+ /** ISO 8601 timestamp of when root generated it. */
29
+ generatedAt: string;
30
+ apps: RegistryApp[];
31
+ }
32
+ /**
33
+ * Validates untrusted JSON. Returns null for anything that isn't a v1 registry. Malformed
34
+ * individual entries are dropped rather than failing the whole registry.
35
+ */
36
+ export declare function parseRegistry(value: unknown): Registry | null;
37
+ /** True if `candidate` was generated after `current` (or there's no current). */
38
+ export declare function isNewerRegistry(candidate: Registry, current: Registry | null | undefined): boolean;
39
+ /** Apps shown in the shared nav, in display order. */
40
+ export declare function navApps(registry: Registry | null | undefined): RegistryApp[];
41
+ /** Fetches and validates a registry. Never throws: returns null on any failure. */
42
+ export declare function fetchRegistry(url?: string, init?: RequestInit): Promise<Registry | null>;
43
+ /**
44
+ * Build-time snapshot of the production registry, baked into each app's static HTML so the
45
+ * nav renders complete with no layout shift. Call it from a server component (e.g. the root
46
+ * layout). Returns null if the registry is unreachable; the nav then fills in at runtime.
47
+ */
48
+ export declare function getRegistrySnapshot(): Promise<Registry | null>;
49
+ //#endregion
@@ -0,0 +1,84 @@
1
+ //#region src/registry/index.ts
2
+ /**
3
+ * The app registry (spec §7): the list of toyonix apps, published by the root site at
4
+ * /registry.json and used for cross-app navigation. The schema is a frozen contract: an
5
+ * incompatible change is a major release and needs a new `version`.
6
+ */
7
+ const REGISTRY_VERSION = 1;
8
+ /** Absolute URL of the production registry. Used at build time and in local dev. */
9
+ const REGISTRY_URL = "https://toyonix.github.io/registry.json";
10
+ /** Path of the registry on the current origin (root-relative, NOT basePath-relative). */
11
+ const REGISTRY_PATH = "/registry.json";
12
+ const STATUSES = [
13
+ "live",
14
+ "beta",
15
+ "archived"
16
+ ];
17
+ function isRecord(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ function parseApp(value) {
21
+ if (!isRecord(value)) return null;
22
+ const { id, title, description, path, status, nav, order } = value;
23
+ if (typeof id !== "string" || typeof title !== "string" || typeof path !== "string") return null;
24
+ return {
25
+ id,
26
+ title,
27
+ path,
28
+ description: typeof description === "string" ? description : "",
29
+ status: STATUSES.includes(status) ? status : "live",
30
+ nav: typeof nav === "boolean" ? nav : true,
31
+ order: typeof order === "number" && Number.isFinite(order) ? order : 100
32
+ };
33
+ }
34
+ /**
35
+ * Validates untrusted JSON. Returns null for anything that isn't a v1 registry. Malformed
36
+ * individual entries are dropped rather than failing the whole registry.
37
+ */
38
+ function parseRegistry(value) {
39
+ if (!isRecord(value) || value.version !== 1) return null;
40
+ if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) return null;
41
+ if (!Array.isArray(value.apps)) return null;
42
+ const apps = value.apps.map(parseApp).filter((app) => app !== null);
43
+ return {
44
+ version: 1,
45
+ generatedAt: value.generatedAt,
46
+ apps
47
+ };
48
+ }
49
+ /** True if `candidate` was generated after `current` (or there's no current). */
50
+ function isNewerRegistry(candidate, current) {
51
+ if (!current) return true;
52
+ return Date.parse(candidate.generatedAt) > Date.parse(current.generatedAt);
53
+ }
54
+ /** Apps shown in the shared nav, in display order. */
55
+ function navApps(registry) {
56
+ if (!registry) return [];
57
+ return registry.apps.filter((app) => app.nav && app.status !== "archived").sort((a, b) => a.order - b.order || a.title.localeCompare(b.title));
58
+ }
59
+ /** Fetches and validates a registry. Never throws: returns null on any failure. */
60
+ async function fetchRegistry(url = REGISTRY_PATH, init) {
61
+ try {
62
+ const response = await fetch(url, {
63
+ ...init,
64
+ headers: {
65
+ accept: "application/json",
66
+ ...init?.headers
67
+ }
68
+ });
69
+ if (!response.ok) return null;
70
+ return parseRegistry(await response.json());
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ /**
76
+ * Build-time snapshot of the production registry, baked into each app's static HTML so the
77
+ * nav renders complete with no layout shift. Call it from a server component (e.g. the root
78
+ * layout). Returns null if the registry is unreachable; the nav then fills in at runtime.
79
+ */
80
+ async function getRegistrySnapshot() {
81
+ return fetchRegistry(REGISTRY_URL, { signal: AbortSignal.timeout(1e4) });
82
+ }
83
+ //#endregion
84
+ export { REGISTRY_PATH, REGISTRY_URL, REGISTRY_VERSION, fetchRegistry, getRegistrySnapshot, isNewerRegistry, navApps, parseRegistry };
@@ -0,0 +1,15 @@
1
+ //#region src/slugs.d.ts
2
+ /**
3
+ * Top-level paths owned by the root site. An app repo with one of these names would collide
4
+ * with root, so the registry build rejects it. Adding an entry is a minor release only if no
5
+ * live app already uses that slug.
6
+ */
7
+ export declare const RESERVED_SLUGS: readonly string[];
8
+ /** Lowercase kebab-case, as required for repo names (repo name = URL slug = app id). */
9
+ export declare function isValidSlug(slug: string): boolean;
10
+ export declare function isReservedSlug(slug: string): boolean;
11
+ /** True for the root repo (`<owner>.github.io`), which is served at `/`. */
12
+ export declare function isRootRepo(repoName: string): boolean;
13
+ /** Repo name → app id: "root" for `<owner>.github.io`, otherwise the repo name. */
14
+ export declare function repoToAppId(repoName: string): string;
15
+ //#endregion
package/dist/slugs.js ADDED
@@ -0,0 +1,37 @@
1
+ import { ROOT_APP_ID } from "./env.js";
2
+ //#region src/slugs.ts
3
+ /**
4
+ * Top-level paths owned by the root site. An app repo with one of these names would collide
5
+ * with root, so the registry build rejects it. Adding an entry is a minor release only if no
6
+ * live app already uses that slug.
7
+ */
8
+ const RESERVED_SLUGS = Object.freeze([
9
+ ROOT_APP_ID,
10
+ "_next",
11
+ "404",
12
+ "404.html",
13
+ "index.html",
14
+ "registry.json",
15
+ "robots.txt",
16
+ "sitemap.xml",
17
+ "favicon.ico",
18
+ "assets"
19
+ ]);
20
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
21
+ /** Lowercase kebab-case, as required for repo names (repo name = URL slug = app id). */
22
+ function isValidSlug(slug) {
23
+ return SLUG.test(slug);
24
+ }
25
+ function isReservedSlug(slug) {
26
+ return RESERVED_SLUGS.includes(slug.toLowerCase());
27
+ }
28
+ /** True for the root repo (`<owner>.github.io`), which is served at `/`. */
29
+ function isRootRepo(repoName) {
30
+ return repoName.toLowerCase().endsWith(".github.io");
31
+ }
32
+ /** Repo name → app id: "root" for `<owner>.github.io`, otherwise the repo name. */
33
+ function repoToAppId(repoName) {
34
+ return isRootRepo(repoName) ? ROOT_APP_ID : repoName;
35
+ }
36
+ //#endregion
37
+ export { RESERVED_SLUGS, isReservedSlug, isRootRepo, isValidSlug, repoToAppId };
@@ -0,0 +1,39 @@
1
+ import { ThemePreference } from "../theme.js";
2
+ //#region src/storage/index.d.ts
3
+ export declare const STORAGE_PREFIX = "toyonix";
4
+ export declare const SHARED_SCOPE = "shared";
5
+ export declare const ENVELOPE_VERSION = 1;
6
+ /** Cross-app settings. Adding a key is a minor release; changing a key's type is major. */
7
+ export interface SharedKeys {
8
+ theme: ThemePreference;
9
+ }
10
+ export declare function storageKey(scope: string, key: string): string;
11
+ export interface AppStore {
12
+ /** Returns the stored value, or undefined if missing/corrupt. Validate it before trusting it. */
13
+ get<T = unknown>(key: string): T | undefined;
14
+ /** Returns false if the value couldn't be written (storage disabled or full). */
15
+ set(key: string, value: unknown): boolean;
16
+ remove(key: string): void;
17
+ subscribe(key: string, listener: () => void): () => void;
18
+ /** The full storage key, e.g. "toyonix:gen:draft". */
19
+ key(key: string): string;
20
+ }
21
+ export interface SharedStore {
22
+ get<K extends keyof SharedKeys>(key: K): SharedKeys[K] | undefined;
23
+ set<K extends keyof SharedKeys>(key: K, value: SharedKeys[K]): boolean;
24
+ remove(key: keyof SharedKeys): void;
25
+ subscribe(key: keyof SharedKeys, listener: () => void): () => void;
26
+ }
27
+ export declare const storage: {
28
+ /** App-private localStorage: toyonix:<appId>:<key>. */
29
+ readonly app: AppStore;
30
+ /** App-private sessionStorage: toyonix:<appId>:<key>. */
31
+ readonly session: AppStore;
32
+ /** Cross-app settings: toyonix:shared:<key>. Only kit-defined keys. */
33
+ readonly shared: SharedStore;
34
+ };
35
+ /** IndexedDB database name for this app: "toyonix:<appId>:<name>". */
36
+ export declare function dbName(name: string): string;
37
+ /** Cookie path for this app ("/<slug>/", or "/" for root). Always set app cookies with it. */
38
+ export declare function cookiePath(): string;
39
+ //#endregion
@@ -0,0 +1,123 @@
1
+ import { getAppId, getBasePath } from "../env.js";
2
+ import { isThemePreference } from "../theme.js";
3
+ //#region src/storage/index.ts
4
+ /**
5
+ * Namespaced browser storage (spec §6). Every toyonix app shares one origin, so all storage
6
+ * access goes through here:
7
+ *
8
+ * toyonix:shared:<key> cross-app settings, only the keys defined in SharedKeys
9
+ * toyonix:<appId>:<key> app-private data
10
+ *
11
+ * Values are stored as JSON in a versioned envelope: { "v": 1, "data": … }.
12
+ * Shared keys and the envelope format are frozen contracts: changing them is a major release.
13
+ *
14
+ * Never store secrets here: any script on the origin (any toyonix app) can read it.
15
+ */
16
+ const STORAGE_PREFIX = "toyonix";
17
+ const SHARED_SCOPE = "shared";
18
+ const ENVELOPE_VERSION = 1;
19
+ const SHARED_VALIDATORS = { theme: isThemePreference };
20
+ /** Fired on window when a toyonix key changes in *this* tab (the `storage` event covers other tabs). */
21
+ const CHANGE_EVENT = "toyonix:storage";
22
+ const KEY_PART = /^[A-Za-z0-9._-]+$/;
23
+ function storageKey(scope, key) {
24
+ if (!KEY_PART.test(scope) || !KEY_PART.test(key)) throw new Error(`Invalid storage key "${scope}:${key}": use letters, digits, ".", "_" or "-".`);
25
+ return `${STORAGE_PREFIX}:${scope}:${key}`;
26
+ }
27
+ function area(backend) {
28
+ try {
29
+ if (typeof window === "undefined") return void 0;
30
+ return backend === "local" ? window.localStorage : window.sessionStorage;
31
+ } catch {
32
+ return;
33
+ }
34
+ }
35
+ function readEnvelope(storage, fullKey) {
36
+ if (!storage) return void 0;
37
+ try {
38
+ const raw = storage.getItem(fullKey);
39
+ if (raw === null) return void 0;
40
+ const parsed = JSON.parse(raw);
41
+ if (parsed && typeof parsed === "object" && parsed.v === 1) return parsed.data;
42
+ } catch {}
43
+ }
44
+ function writeEnvelope(storage, fullKey, data) {
45
+ if (!storage) return false;
46
+ try {
47
+ const envelope = {
48
+ v: 1,
49
+ data
50
+ };
51
+ storage.setItem(fullKey, JSON.stringify(envelope));
52
+ notify(fullKey);
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+ function removeKey(storage, fullKey) {
59
+ if (!storage) return;
60
+ try {
61
+ storage.removeItem(fullKey);
62
+ notify(fullKey);
63
+ } catch {}
64
+ }
65
+ function notify(fullKey) {
66
+ if (typeof window === "undefined") return;
67
+ window.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail: { key: fullKey } }));
68
+ }
69
+ /** Calls `listener` when `fullKey` changes in this tab or another tab. Returns an unsubscribe. */
70
+ function subscribeKey(fullKey, listener) {
71
+ if (typeof window === "undefined") return () => {};
72
+ const onStorage = (event) => {
73
+ if (event.key === fullKey || event.key === null) listener();
74
+ };
75
+ const onLocal = (event) => {
76
+ if (event.detail.key === fullKey) listener();
77
+ };
78
+ window.addEventListener("storage", onStorage);
79
+ window.addEventListener(CHANGE_EVENT, onLocal);
80
+ return () => {
81
+ window.removeEventListener("storage", onStorage);
82
+ window.removeEventListener(CHANGE_EVENT, onLocal);
83
+ };
84
+ }
85
+ function appStore(backend) {
86
+ const full = (key) => storageKey(getAppId(), key);
87
+ return {
88
+ get: (key) => readEnvelope(area(backend), full(key)),
89
+ set: (key, value) => writeEnvelope(area(backend), full(key), value),
90
+ remove: (key) => removeKey(area(backend), full(key)),
91
+ subscribe: (key, listener) => subscribeKey(full(key), listener),
92
+ key: full
93
+ };
94
+ }
95
+ const storage = {
96
+ /** App-private localStorage: toyonix:<appId>:<key>. */
97
+ app: appStore("local"),
98
+ /** App-private sessionStorage: toyonix:<appId>:<key>. */
99
+ session: appStore("session"),
100
+ /** Cross-app settings: toyonix:shared:<key>. Only kit-defined keys. */
101
+ shared: {
102
+ get(key) {
103
+ const value = readEnvelope(area("local"), storageKey(SHARED_SCOPE, key));
104
+ return SHARED_VALIDATORS[key](value) ? value : void 0;
105
+ },
106
+ set(key, value) {
107
+ if (!SHARED_VALIDATORS[key](value)) throw new Error(`Invalid value for shared key "${key}"`);
108
+ return writeEnvelope(area("local"), storageKey(SHARED_SCOPE, key), value);
109
+ },
110
+ remove: (key) => removeKey(area("local"), storageKey(SHARED_SCOPE, key)),
111
+ subscribe: (key, listener) => subscribeKey(storageKey(SHARED_SCOPE, key), listener)
112
+ }
113
+ };
114
+ /** IndexedDB database name for this app: "toyonix:<appId>:<name>". */
115
+ function dbName(name) {
116
+ return storageKey(getAppId(), name);
117
+ }
118
+ /** Cookie path for this app ("/<slug>/", or "/" for root). Always set app cookies with it. */
119
+ function cookiePath() {
120
+ return `${getBasePath()}/`;
121
+ }
122
+ //#endregion
123
+ export { ENVELOPE_VERSION, SHARED_SCOPE, STORAGE_PREFIX, cookiePath, dbName, storage, storageKey };
@@ -0,0 +1,19 @@
1
+ //#region src/theme.d.ts
2
+ /**
3
+ * Theme preference (light | dark | system), shared by every toyonix app through the
4
+ * `toyonix:shared:theme` storage key. The resolved theme is written to html[data-theme].
5
+ */
6
+ export type ThemePreference = "light" | "dark" | "system";
7
+ export type ResolvedTheme = "light" | "dark";
8
+ export declare const THEME_PREFERENCES: readonly ThemePreference[];
9
+ export declare function isThemePreference(value: unknown): value is ThemePreference;
10
+ export declare function resolveTheme(preference: ThemePreference, systemPrefersDark: boolean): ResolvedTheme;
11
+ /** Applies a resolved theme to the document (no-op outside the browser). */
12
+ export declare function applyResolvedTheme(theme: ResolvedTheme): void;
13
+ /**
14
+ * Inline script for <head> that sets html[data-theme] before first paint, so there's no flash
15
+ * of the wrong theme. Must stay in sync with storage's key and envelope format; it can't import
16
+ * anything because it runs before any bundle loads.
17
+ */
18
+ export declare const THEME_SCRIPT = "(function(){var p=\"system\";try{var e=JSON.parse(localStorage.getItem(\"toyonix:shared:theme\"));if(e&&e.v===1&&(e.data===\"light\"||e.data===\"dark\"||e.data===\"system\"))p=e.data}catch(_){}try{var d=p===\"dark\"||(p===\"system\"&&matchMedia(\"(prefers-color-scheme: dark)\").matches);document.documentElement.dataset.theme=d?\"dark\":\"light\"}catch(_){}})();";
19
+ //#endregion
package/dist/theme.js ADDED
@@ -0,0 +1,26 @@
1
+ //#region src/theme.ts
2
+ const THEME_PREFERENCES = [
3
+ "light",
4
+ "dark",
5
+ "system"
6
+ ];
7
+ function isThemePreference(value) {
8
+ return value === "light" || value === "dark" || value === "system";
9
+ }
10
+ function resolveTheme(preference, systemPrefersDark) {
11
+ if (preference === "system") return systemPrefersDark ? "dark" : "light";
12
+ return preference;
13
+ }
14
+ /** Applies a resolved theme to the document (no-op outside the browser). */
15
+ function applyResolvedTheme(theme) {
16
+ if (typeof document === "undefined") return;
17
+ document.documentElement.dataset.theme = theme;
18
+ }
19
+ /**
20
+ * Inline script for <head> that sets html[data-theme] before first paint, so there's no flash
21
+ * of the wrong theme. Must stay in sync with storage's key and envelope format; it can't import
22
+ * anything because it runs before any bundle loads.
23
+ */
24
+ const THEME_SCRIPT = `(function(){var p="system";try{var e=JSON.parse(localStorage.getItem("toyonix:shared:theme"));if(e&&e.v===1&&(e.data==="light"||e.data==="dark"||e.data==="system"))p=e.data}catch(_){}try{var d=p==="dark"||(p==="system"&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch(_){}})();`;
25
+ //#endregion
26
+ export { THEME_PREFERENCES, THEME_SCRIPT, applyResolvedTheme, isThemePreference, resolveTheme };
package/package.json CHANGED
@@ -1,6 +1,113 @@
1
1
  {
2
2
  "name": "@toyonix/web-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Shared design system, components, and tooling for every toyonix static site.",
5
+ "license": "MIT",
6
+ "author": "ToYoNiX",
7
+ "homepage": "https://github.com/ToYoNiX/web-kit#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ToYoNiX/web-kit.git",
11
+ "directory": "packages/web-kit"
12
+ },
13
+ "bugs": "https://github.com/ToYoNiX/web-kit/issues",
14
+ "type": "module",
15
+ "sideEffects": [
16
+ "**/*.css"
17
+ ],
18
+ "files": [
19
+ "dist",
20
+ "config"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./storage": {
28
+ "types": "./dist/storage/index.d.ts",
29
+ "default": "./dist/storage/index.js"
30
+ },
31
+ "./registry": {
32
+ "types": "./dist/registry/index.d.ts",
33
+ "default": "./dist/registry/index.js"
34
+ },
35
+ "./registry/build": {
36
+ "types": "./dist/registry/build.d.ts",
37
+ "default": "./dist/registry/build.js"
38
+ },
39
+ "./react": {
40
+ "types": "./dist/react/index.d.ts",
41
+ "default": "./dist/react/index.js"
42
+ },
43
+ "./next": {
44
+ "types": "./dist/next/index.d.ts",
45
+ "default": "./dist/next/index.js"
46
+ },
47
+ "./css": "./dist/css/index.css",
48
+ "./css/tokens.css": "./dist/css/tokens.css",
49
+ "./css/fonts.css": "./dist/css/fonts.css",
50
+ "./tailwind.css": "./dist/css/tailwind.css",
51
+ "./eslint": "./config/eslint.js",
52
+ "./prettier": "./config/prettier.js",
53
+ "./tsconfig.json": "./config/tsconfig.base.json",
54
+ "./package.json": "./package.json"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "provenance": true
59
+ },
60
+ "scripts": {
61
+ "build": "tsdown && node scripts/build-css.mjs",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "typecheck": "tsc --noEmit"
65
+ },
66
+ "dependencies": {
67
+ "eslint-config-next": "^16.4.2",
68
+ "prettier-plugin-tailwindcss": "^0.8.1",
69
+ "radix-ui": "^1.8.1",
70
+ "typescript-eslint": "^8.71.2"
71
+ },
72
+ "peerDependencies": {
73
+ "eslint": "^9.0.0",
74
+ "next": ">=16.0.0 <18.0.0",
75
+ "prettier": "^3.0.0",
76
+ "react": "^19.0.0",
77
+ "react-dom": "^19.0.0",
78
+ "tailwindcss": "^4.0.0",
79
+ "typescript": ">=5.5.0 <6.1.0"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "eslint": {
83
+ "optional": true
84
+ },
85
+ "next": {
86
+ "optional": true
87
+ },
88
+ "prettier": {
89
+ "optional": true
90
+ },
91
+ "tailwindcss": {
92
+ "optional": true
93
+ },
94
+ "typescript": {
95
+ "optional": true
96
+ }
97
+ },
98
+ "devDependencies": {
99
+ "@fontsource-variable/newsreader": "^5.3.0",
100
+ "@fontsource/space-grotesk": "^5.3.0",
101
+ "@fontsource/space-mono": "^5.3.0",
102
+ "@testing-library/react": "^16.3.3",
103
+ "@types/react": "^19.3.0",
104
+ "@types/react-dom": "^19.3.0",
105
+ "jsdom": "^30.1.3",
106
+ "next": "^16.4.2",
107
+ "react": "^19.3.1",
108
+ "react-dom": "^19.3.1",
109
+ "tsdown": "^0.23.1",
110
+ "vite": "^8.3.4",
111
+ "vitest": "^5.0.5"
112
+ }
113
+ }