From a82f5f2dfdabf1732e84a0996e82e3d6e2255ad4 Mon Sep 17 00:00:00 2001 From: robonen Date: Wed, 10 Jun 2026 15:09:46 +0700 Subject: [PATCH] feat(storage): enhance useStorageAsync with cross-instance sync and event handling --- .../src/browsers/cookies/index.test.ts | 213 +++++ core/platform/src/browsers/cookies/index.ts | 308 ++++++ core/platform/src/browsers/index.ts | 1 + docs/app/assets/css/main.css | 152 ++- docs/app/components/DocsBadge.vue | 19 +- docs/app/components/DocsSearch.vue | 12 +- docs/app/components/DocsTag.vue | 8 +- docs/app/components/DocsToc.vue | 4 +- docs/app/layouts/default.vue | 142 ++- docs/app/pages/[package]/[utility].vue | 8 +- docs/app/pages/[package]/index.vue | 124 ++- docs/app/pages/index.vue | 65 +- docs/modules/extractor/index.ts | 27 +- docs/nuxt.config.ts | 9 +- vue/toolkit/src/composables/browser/index.ts | 1 + .../browser/useOtpCredentials/demo.vue | 104 +++ .../browser/useOtpCredentials/index.test.ts | 308 ++++++ .../browser/useOtpCredentials/index.ts | 281 ++++++ vue/toolkit/src/composables/storage/index.ts | 1 + .../composables/storage/useCookie/demo.vue | 98 ++ .../storage/useCookie/index.test.ts | 738 +++++++++++++++ .../composables/storage/useCookie/index.ts | 874 ++++++++++++++++++ .../storage/useStorageAsync/index.test.ts | 169 ++++ .../storage/useStorageAsync/index.ts | 242 ++++- vue/toolkit/src/types/dom.ts | 16 + 25 files changed, 3725 insertions(+), 199 deletions(-) create mode 100644 core/platform/src/browsers/cookies/index.test.ts create mode 100644 core/platform/src/browsers/cookies/index.ts create mode 100644 vue/toolkit/src/composables/browser/useOtpCredentials/demo.vue create mode 100644 vue/toolkit/src/composables/browser/useOtpCredentials/index.test.ts create mode 100644 vue/toolkit/src/composables/browser/useOtpCredentials/index.ts create mode 100644 vue/toolkit/src/composables/storage/useCookie/demo.vue create mode 100644 vue/toolkit/src/composables/storage/useCookie/index.test.ts create mode 100644 vue/toolkit/src/composables/storage/useCookie/index.ts diff --git a/core/platform/src/browsers/cookies/index.test.ts b/core/platform/src/browsers/cookies/index.test.ts new file mode 100644 index 0000000..a69e4c4 --- /dev/null +++ b/core/platform/src/browsers/cookies/index.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeCookieValue, + encodeCookieName, + encodeCookieValue, + getCookieValue, + parseCookieString, + serializeCookie, +} from './index'; + +describe('encodeCookieValue', () => { + it('passes plain values through unchanged', () => { + expect(encodeCookieValue('dark')).toBe('dark'); + expect(encodeCookieValue('abc-123_~.!*')).toBe('abc-123_~.!*'); + }); + + it('keeps RFC 6265-allowed punctuation readable', () => { + expect(encodeCookieValue('a=b&c:d/e?f@g')).toBe('a=b&c:d/e?f@g'); + expect(encodeCookieValue('#$+<>[]^`{|}')).toBe('#$+<>[]^`{|}'); + }); + + it('encodes characters cookies cannot contain', () => { + expect(encodeCookieValue('a;b')).toBe('a%3Bb'); + expect(encodeCookieValue('a b')).toBe('a%20b'); + expect(encodeCookieValue('a,b')).toBe('a%2Cb'); + expect(encodeCookieValue('a"b')).toBe('a%22b'); + expect(encodeCookieValue('a\\b')).toBe('a%5Cb'); + expect(encodeCookieValue('50%')).toBe('50%25'); + }); + + it('round-trips through decodeCookieValue', () => { + const value = 'json:{"a": "b, c"}; 50% off\\done'; + + expect(decodeCookieValue(encodeCookieValue(value))).toBe(value); + }); +}); + +describe('decodeCookieValue', () => { + it('decodes percent escapes', () => { + expect(decodeCookieValue('a%3Bb')).toBe('a;b'); + expect(decodeCookieValue('%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82')).toBe('привет'); + }); + + it('returns malformed escapes as-is instead of throwing', () => { + expect(decodeCookieValue('100%')).toBe('100%'); + expect(decodeCookieValue('%E0%A4%A')).toBe('%E0%A4%A'); + }); + + it('unwraps DQUOTE-wrapped values', () => { + expect(decodeCookieValue('"dark"')).toBe('dark'); + expect(decodeCookieValue('"a%3Bb"')).toBe('a;b'); + expect(decodeCookieValue('"')).toBe('"'); + }); +}); + +describe('encodeCookieName', () => { + it('passes typical names through unchanged', () => { + expect(encodeCookieName('session-id')).toBe('session-id'); + expect(encodeCookieName('user_pref.v2')).toBe('user_pref.v2'); + }); + + it('encodes separators and whitespace', () => { + expect(encodeCookieName('user name')).toBe('user%20name'); + expect(encodeCookieName('a=b')).toBe('a%3Db'); + expect(encodeCookieName('a;b')).toBe('a%3Bb'); + }); + + it('escapes parentheses', () => { + expect(encodeCookieName('a(b)')).toBe('a%28b%29'); + }); +}); + +describe('parseCookieString', () => { + it('parses an empty string to an empty map', () => { + expect(parseCookieString('').size).toBe(0); + }); + + it('parses multiple cookies', () => { + const map = parseCookieString('theme=dark; sid=abc123'); + + expect(map.get('theme')).toBe('dark'); + expect(map.get('sid')).toBe('abc123'); + }); + + it('decodes names and values by default', () => { + const map = parseCookieString('user%20name=a%3Bb'); + + expect(map.get('user name')).toBe('a;b'); + }); + + it('keeps raw values with an identity decoder', () => { + const map = parseCookieString('sid=a%3Bb', value => value); + + expect(map.get('sid')).toBe('a%3Bb'); + }); + + it('keeps the first occurrence per name (most specific path wins)', () => { + const map = parseCookieString('dup=specific; dup=generic'); + + expect(map.get('dup')).toBe('specific'); + }); + + it('keeps the value intact when it contains "="', () => { + const map = parseCookieString('token=a=b=c'); + + expect(map.get('token')).toBe('a=b=c'); + }); + + it('unwraps double-quoted values via the default decoder, keeps them raw with identity', () => { + expect(parseCookieString('quoted="hello"').get('quoted')).toBe('hello'); + // The raw layer is verbatim — matching what the Cookie Store API reports + expect(parseCookieString('quoted="hello"', value => value).get('quoted')).toBe('"hello"'); + }); + + it('parses nameless cookies under the empty name', () => { + expect(parseCookieString('=bare').get('')).toBe('bare'); + expect(parseCookieString('bare').get('')).toBe('bare'); + }); +}); + +describe('getCookieValue', () => { + it('finds a cookie by name', () => { + expect(getCookieValue('theme=dark; sid=abc', 'sid')).toBe('abc'); + expect(getCookieValue('theme=dark; sid=abc', 'theme')).toBe('dark'); + }); + + it('returns null for a missing cookie or empty string', () => { + expect(getCookieValue('theme=dark', 'missing')).toBeNull(); + expect(getCookieValue('', 'any')).toBeNull(); + }); + + it('decodes the value by default and supports an identity decoder', () => { + expect(getCookieValue('sid=a%3Bb', 'sid')).toBe('a;b'); + expect(getCookieValue('sid=a%3Bb', 'sid', value => value)).toBe('a%3Bb'); + }); + + it('matches encoded stored names against the raw name', () => { + expect(getCookieValue('user%20name=v', 'user name')).toBe('v'); + }); + + it('keeps the first occurrence and unwraps quotes via the default decoder', () => { + expect(getCookieValue('dup=specific; dup=generic', 'dup')).toBe('specific'); + expect(getCookieValue('quoted="hello"', 'quoted')).toBe('hello'); + expect(getCookieValue('quoted="hello"', 'quoted', value => value)).toBe('"hello"'); + }); + + it('matches parseCookieString semantics for edge cases', () => { + for (const cookie of ['token=a=b=c', '=bare', 'bare', 'a=1; ; b=2']) { + const map = parseCookieString(cookie); + + for (const [name, value] of map) + expect(getCookieValue(cookie, name)).toBe(value); + } + }); +}); + +describe('serializeCookie', () => { + it('always emits Path and SameSite', () => { + expect(serializeCookie('theme', 'dark')).toBe('theme=dark; Path=/; SameSite=Lax'); + }); + + it('emits the provided attributes', () => { + expect(serializeCookie('sid', 'abc', { + path: '/app', + domain: 'example.com', + maxAge: 3600, + secure: true, + sameSite: 'strict', + })).toBe('sid=abc; Path=/app; Domain=example.com; SameSite=Strict; Max-Age=3600; Secure'); + }); + + it('serializes expires from a Date and from epoch milliseconds', () => { + const date = new Date('2030-01-01T00:00:00Z'); + + expect(serializeCookie('a', 'b', { expires: date })).toContain(`Expires=${date.toUTCString()}`); + expect(serializeCookie('a', 'b', { expires: date.getTime() })).toContain(`Expires=${date.toUTCString()}`); + }); + + it('emits Partitioned together with Secure', () => { + expect(serializeCookie('a', 'b', { secure: true, partitioned: true })).toContain('; Secure; Partitioned'); + }); + + it('expresses deletion as a non-positive Max-Age', () => { + expect(serializeCookie('a', '', { maxAge: 0 })).toBe('a=; Path=/; SameSite=Lax; Max-Age=0'); + }); + + it('throws on a non-encoded name', () => { + expect(() => serializeCookie('a b', 'v')).toThrow(TypeError); + expect(() => serializeCookie('a=b', 'v')).toThrow(TypeError); + expect(() => serializeCookie('a;b', 'v')).toThrow(TypeError); + }); + + it('throws on a non-encoded value (attribute injection)', () => { + expect(() => serializeCookie('a', 'v; Path=/admin')).toThrow(TypeError); + expect(() => serializeCookie('a', 'v w')).toThrow(TypeError); + expect(() => serializeCookie('a', 'a,b')).toThrow(TypeError); + expect(serializeCookie('a', '"quoted"')).toContain('a="quoted"'); + }); + + it('throws on SameSite=None without Secure', () => { + expect(() => serializeCookie('a', 'b', { sameSite: 'none' })).toThrow(TypeError); + expect(serializeCookie('a', 'b', { sameSite: 'none', secure: true })).toContain('SameSite=None'); + }); + + it('throws on Partitioned without Secure', () => { + expect(() => serializeCookie('a', 'b', { partitioned: true })).toThrow(TypeError); + }); + + it('throws when name plus value exceeds 4096 bytes (the "=" is not counted)', () => { + expect(() => serializeCookie('big', 'x'.repeat(4094))).toThrow(RangeError); + expect(serializeCookie('big', 'x'.repeat(4093))).toContain('big='); + }); +}); diff --git a/core/platform/src/browsers/cookies/index.ts b/core/platform/src/browsers/cookies/index.ts new file mode 100644 index 0000000..6b06fad --- /dev/null +++ b/core/platform/src/browsers/cookies/index.ts @@ -0,0 +1,308 @@ +/** + * Write-time cookie attributes (the `Set-Cookie` half of RFC 6265). + * + * Cookies expose no way to read attributes back — these only describe how a + * cookie is written (and must be repeated to overwrite or delete it, since a + * cookie's identity is its `name` + `domain` + `path`). + */ +export interface CookieAttributes { + /** + * The path the cookie is scoped to. + * + * @default '/' + */ + path?: string; + /** + * The domain the cookie is scoped to. Omitted = host-only cookie. + */ + domain?: string; + /** + * Lifetime in seconds. Takes precedence over `expires` in browsers when both + * are present (RFC 6265 §4.1.2.2). Non-positive values expire the cookie. + */ + maxAge?: number; + /** + * Expiry as a `Date` or Unix epoch **milliseconds**. Omitting both `maxAge` + * and `expires` creates a session cookie. + */ + expires?: Date | number; + /** + * Only send the cookie over HTTPS. + */ + secure?: boolean; + /** + * `SameSite` attribute. `'none'` requires `secure: true` — browsers silently + * drop the cookie otherwise, so {@link serializeCookie} fails loudly instead. + * + * @default 'lax' + */ + sameSite?: 'lax' | 'strict' | 'none'; + /** + * Partition the cookie by top-level site (CHIPS). Requires `secure: true`. + * + * @default false + */ + partitioned?: boolean; +} + +/** + * Browsers commonly enforce RFC 6265's minimum of 4096 bytes for `name=value`; + * anything longer is silently dropped, so {@link serializeCookie} throws instead. + */ +const MAX_COOKIE_BYTES = 4096; + +// RFC 6265 cookie-octet allows these characters, but encodeURIComponent escapes +// them — un-escape to keep values readable and js-cookie-compatible: +// %23 # | %24 $ | %26 & | %2B + | %2F / | %3A : | %3C < | %3D = | %3E > | +// %3F ? | %40 @ | %5B [ | %5D ] | %5E ^ | %60 ` | %7B { | %7C | | %7D } +const ALLOWED_VALUE_ESCAPES = /%(?:2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g; + +// Cookie names are RFC 2616 tokens; restore the token characters +// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C | +const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g; + +/** + * @name encodeCookieValue + * @category Browsers + * @description Percent-encodes only the characters a cookie value cannot contain + * per RFC 6265 (controls, whitespace, `"` `,` `;` `\` and `%` itself), leaving + * everything else readable. Compatible with js-cookie's default write converter. + * + * @param {string} value The raw cookie value + * @returns {string} The encoded cookie value + * + * @example + * encodeCookieValue('a=b; c'); // 'a=b%3B%20c' + * + * @since 0.0.5 + */ +export function encodeCookieValue(value: string): string { + return encodeURIComponent(value).replaceAll(ALLOWED_VALUE_ESCAPES, decodeURIComponent); +} + +/** + * @name decodeCookieValue + * @category Browsers + * @description Decodes a cookie value: unwraps an RFC 6265 DQUOTE-wrapped + * value (the quotes are transport dressing, not payload), then decodes + * percent-escapes. Malformed escapes (e.g. third-party cookies that never + * used percent-encoding) are returned as-is instead of throwing. + * + * @param {string} value The encoded cookie value + * @returns {string} The decoded cookie value + * + * @example + * decodeCookieValue('a%3Bb'); // 'a;b' + * + * @example + * decodeCookieValue('"dark"'); // 'dark' + * + * @since 0.0.5 + */ +export function decodeCookieValue(value: string): string { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) + value = value.slice(1, -1); + + try { + return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent); + } + catch { + return value; + } +} + +/** + * @name encodeCookieName + * @category Browsers + * @description Percent-encodes the characters a cookie name cannot contain + * (cookie names are RFC 2616 tokens). Typical names — letters, digits, `-`, + * `_`, `.` — pass through unchanged. `(` and `)` are escaped as `%28`/`%29`. + * + * @param {string} name The raw cookie name + * @returns {string} The encoded cookie name + * + * @example + * encodeCookieName('user name'); // 'user%20name' + * + * @since 0.0.5 + */ +export function encodeCookieName(name: string): string { + return encodeURIComponent(name) + .replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent) + .replaceAll(/[()]/g, c => c === '(' ? '%28' : '%29'); +} + +/** + * @name parseCookieString + * @category Browsers + * @description Parses a `document.cookie`-style string (`'a=1; b=2'`) into a + * `Map` of decoded names to values. Keeps the **first** occurrence per name — + * browsers order cookies most-specific-path first, so the first one is the one + * a server would use. The raw value is passed to `decode` verbatim (including + * any wrapping quotes) so it matches what the Cookie Store API would report; + * the default decoder unwraps quotes and percent-escapes. + * + * @param {string} cookie The cookie string to parse + * @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep raw encoded values + * @returns {Map} Decoded `name -> value` pairs + * + * @example + * parseCookieString('theme=dark; sid=a%3Bb'); // Map { 'theme' => 'dark', 'sid' => 'a;b' } + * + * @since 0.0.5 + */ +export function parseCookieString(cookie: string, decode: (value: string) => string = decodeCookieValue): Map { + const result = new Map(); + + for (const pair of cookie.split('; ')) { + if (!pair) + continue; + + const separator = pair.indexOf('='); + + // Nameless cookies ('=value' or bare 'value') parse under the empty name. + const rawName = separator === -1 ? '' : pair.slice(0, separator); + const rawValue = separator === -1 ? pair : pair.slice(separator + 1); + + const name = decodeCookieValue(rawName); + + if (!result.has(name)) + result.set(name, decode(rawValue)); + } + + return result; +} + +/** + * @name getCookieValue + * @category Browsers + * @description Looks up a single cookie in a `document.cookie`-style string + * without building a full map — same first-occurrence and verbatim-raw-value + * semantics as {@link parseCookieString}, but allocation-free for misses and + * cheap for hot paths (reactive reads, polling). + * + * @param {string} cookie The cookie string to search + * @param {string} name The raw (decoded) cookie name to look up + * @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep the raw encoded value + * @returns {string | null} The cookie value, or `null` when absent + * + * @example + * getCookieValue('theme=dark; sid=a%3Bb', 'sid'); // 'a;b' + * + * @since 0.0.5 + */ +export function getCookieValue(cookie: string, name: string, decode: (value: string) => string = decodeCookieValue): string | null { + for (const pair of cookie.split('; ')) { + if (!pair) + continue; + + const separator = pair.indexOf('='); + const rawName = separator === -1 ? '' : pair.slice(0, separator); + + // Stored names are encoded — compare directly first (encoding is the + // identity for typical names), decode only on mismatch. + if (rawName !== name && decodeCookieValue(rawName) !== name) + continue; + + return decode(separator === -1 ? pair : pair.slice(separator + 1)); + } + + return null; +} + +const INVALID_COOKIE_NAME = /[\s;=]/; + +// RFC 6265 cookie-octet (optionally DQUOTE-wrapped): a raw ';' or whitespace +// in a value silently truncates it and injects attributes into the cookie. +const VALID_COOKIE_VALUE = /^"?[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*"?$/; + +// Encoded names/values are ASCII, but the inputs are not guaranteed to be — +// any string under this length cannot exceed MAX_COOKIE_BYTES even at the +// 3-bytes-per-UTF-16-unit worst case, so the common path never pays for an +// actual byte count. +const SAFE_COOKIE_LENGTH = Math.floor(MAX_COOKIE_BYTES / 3); + +let textEncoder: TextEncoder | undefined; + +function exceedsCookieBytes(pair: string): boolean { + if (pair.length <= SAFE_COOKIE_LENGTH) + return false; + + textEncoder ??= new TextEncoder(); + + return textEncoder.encode(pair).length > MAX_COOKIE_BYTES; +} + +/** + * @name serializeCookie + * @category Browsers + * @description Builds a `document.cookie` assignment string from an + * **already-encoded** name and value (see {@link encodeCookieName} / + * {@link encodeCookieValue}) plus {@link CookieAttributes}. `Path` and + * `SameSite` are always emitted explicitly. Fails loudly on combinations + * browsers silently drop: `SameSite=None` or `Partitioned` without `Secure`, + * and `name=value` over 4096 UTF-8 bytes. + * + * @param {string} name The encoded cookie name + * @param {string} value The encoded cookie value + * @param {CookieAttributes} [attributes={}] Write-time attributes + * @returns {string} The string to assign to `document.cookie` + * + * @example + * document.cookie = serializeCookie('theme', 'dark', { maxAge: 3600 }); + * // 'theme=dark; Path=/; SameSite=Lax; Max-Age=3600' + * + * @example + * // Deleting reuses the same identity attributes with a non-positive maxAge + * document.cookie = serializeCookie('theme', '', { maxAge: 0 }); + * + * @since 0.0.5 + */ +export function serializeCookie(name: string, value: string, attributes: CookieAttributes = {}): string { + const { + path = '/', + domain, + maxAge, + expires, + secure = false, + sameSite = 'lax', + partitioned = false, + } = attributes; + + if (INVALID_COOKIE_NAME.test(name)) + throw new TypeError(`[serializeCookie] invalid cookie name "${name}" — encode it with encodeCookieName first`); + + if (!VALID_COOKIE_VALUE.test(value)) + throw new TypeError(`[serializeCookie] invalid cookie value for "${name}" — encode it with encodeCookieValue first`); + + if (sameSite === 'none' && !secure) + throw new TypeError('[serializeCookie] SameSite=None requires Secure — browsers reject the cookie otherwise'); + + if (partitioned && !secure) + throw new TypeError('[serializeCookie] Partitioned requires Secure — browsers reject the cookie otherwise'); + + // Browsers enforce the RFC 6265bis limit on name + value, excluding the '=' + if (exceedsCookieBytes(`${name}${value}`)) + throw new RangeError(`[serializeCookie] cookie "${name}" exceeds ${MAX_COOKIE_BYTES} bytes — browsers drop it silently`); + + let cookie = `${name}=${value}; Path=${path}`; + + if (domain) + cookie += `; Domain=${domain}`; + + cookie += `; SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`; + + if (maxAge !== undefined) + cookie += `; Max-Age=${Math.floor(maxAge)}`; + + if (expires !== undefined) + cookie += `; Expires=${(expires instanceof Date ? expires : new Date(expires)).toUTCString()}`; + + if (secure) + cookie += '; Secure'; + + if (partitioned) + cookie += '; Partitioned'; + + return cookie; +} diff --git a/core/platform/src/browsers/index.ts b/core/platform/src/browsers/index.ts index 4f0ca1f..bee1ccd 100644 --- a/core/platform/src/browsers/index.ts +++ b/core/platform/src/browsers/index.ts @@ -1,4 +1,5 @@ export * from './animationLifecycle'; +export * from './cookies'; export * from './domStyle'; export * from './focusGuard'; export * from './focusScope'; diff --git a/docs/app/assets/css/main.css b/docs/app/assets/css/main.css index eab104b..4c101fa 100644 --- a/docs/app/assets/css/main.css +++ b/docs/app/assets/css/main.css @@ -9,51 +9,54 @@ @custom-variant dark (&:where(.dark, .dark *)); @theme { - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; + --font-sans: 'IBM Plex Sans', system-ui, -apple-system, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace; + --font-display: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif; - --radius-card: 0.75rem; + --radius-card: 0.5rem; } -/* ── Semantic design tokens — Geist-minimal ──────────────────────────────── */ +/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ── + The site reads like a tool-maker's field manual: warm neutral surfaces, + hairline rules, international-orange accents, code-comment labels. */ :root { - --bg: #ffffff; - --bg-subtle: #fafafa; - --bg-elevated: #ffffff; - --bg-inset: #f4f4f5; - --border: #ececec; - --border-strong: #d8d8dc; - --fg: #18181b; - --fg-muted: #52525b; - --fg-subtle: #a1a1aa; - --accent: #2563eb; - --accent-hover: #1d4ed8; - --accent-fg: #ffffff; - --accent-subtle: #eef3ff; - --accent-text: #2563eb; - --header-bg: rgba(255, 255, 255, 0.72); - --ring: rgba(37, 99, 235, 0.35); - --shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06); + --bg: #faf8f3; + --bg-subtle: #f4f1e8; + --bg-elevated: #fffdf8; + --bg-inset: #eeeadf; + --border: #e5dfd0; + --border-strong: #cfc6b1; + --fg: #211e18; + --fg-muted: #5d574b; + --fg-subtle: #93897a; + --accent: #d9480f; + --accent-hover: #bf3f0d; + --accent-fg: #fffdf8; + --accent-subtle: #f7e7d8; + --accent-text: #c2410c; + --header-bg: rgba(250, 248, 243, 0.82); + --ring: rgba(217, 72, 15, 0.35); + --shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07); color-scheme: light; } .dark { - --bg: #0a0a0a; - --bg-subtle: #0f0f10; - --bg-elevated: #141416; - --bg-inset: #1b1b1e; - --border: #232327; - --border-strong: #34343a; - --fg: #ededed; - --fg-muted: #a1a1aa; - --fg-subtle: #6c6c75; - --accent: #3b82f6; - --accent-hover: #60a5fa; - --accent-fg: #ffffff; - --accent-subtle: #14203a; - --accent-text: #74a8ff; - --header-bg: rgba(10, 10, 10, 0.72); - --ring: rgba(59, 130, 246, 0.4); + --bg: #161310; + --bg-subtle: #1b1813; + --bg-elevated: #211d17; + --bg-inset: #2a251c; + --border: #322c22; + --border-strong: #4a4231; + --fg: #ece7db; + --fg-muted: #b2a995; + --fg-subtle: #7d7363; + --accent: #ff7d33; + --accent-hover: #ff9a59; + --accent-fg: #1d0e04; + --accent-subtle: #3a2415; + --accent-text: #ff9c63; + --header-bg: rgba(22, 19, 16, 0.82); + --ring: rgba(255, 125, 51, 0.4); --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5); color-scheme: dark; } @@ -90,6 +93,55 @@ code, pre, kbd { *::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; } *::-webkit-scrollbar-track { background: transparent; } +/* ── Identity helpers ─────────────────────────────────────────────────────── */ + +/* Section labels styled as code comments: `// sensors` */ +.comment-label { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: lowercase; + color: var(--fg-subtle); +} +.comment-label::before { + content: '// '; + color: var(--accent-text); + opacity: 0.75; +} + +/* Engineering-grid backdrop for heroes, faded out radially */ +.blueprint { + background-image: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px); + background-size: 28px 28px; + mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%); + -webkit-mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%); +} + +/* Staggered rise-in for card grids (landing / package index) */ +@keyframes rise-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} +.stagger > * { + animation: rise-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; +} +.stagger > *:nth-child(1) { animation-delay: 0.03s; } +.stagger > *:nth-child(2) { animation-delay: 0.07s; } +.stagger > *:nth-child(3) { animation-delay: 0.11s; } +.stagger > *:nth-child(4) { animation-delay: 0.15s; } +.stagger > *:nth-child(5) { animation-delay: 0.19s; } +.stagger > *:nth-child(6) { animation-delay: 0.23s; } +.stagger > *:nth-child(7) { animation-delay: 0.27s; } +.stagger > *:nth-child(8) { animation-delay: 0.31s; } +.stagger > *:nth-child(n+9) { animation-delay: 0.35s; } + +@media (prefers-reduced-motion: reduce) { + .stagger > * { animation: none; } +} + /* Shiki dual-theme: switch to dark colors under .dark */ .dark .shiki, .dark .shiki span { @@ -100,6 +152,9 @@ code, pre, kbd { text-decoration: var(--shiki-dark-text-decoration) !important; } +/* Code blocks sit on the warm surface, not on the theme's own background */ +.shiki { background-color: transparent !important; } + /* ── Markdown (guide) typography ──────────────────────────────────────────── */ .prose-docs { color: var(--fg-muted); @@ -109,6 +164,7 @@ code, pre, kbd { .prose-docs > :first-child { margin-top: 0; } .prose-docs h1 { color: var(--fg); + font-family: var(--font-display); font-size: 1.875rem; font-weight: 700; letter-spacing: -0.02em; @@ -116,6 +172,7 @@ code, pre, kbd { } .prose-docs h2 { color: var(--fg); + font-family: var(--font-display); font-size: 1.375rem; font-weight: 650; letter-spacing: -0.01em; @@ -136,27 +193,28 @@ code, pre, kbd { color: var(--accent-text); text-decoration: none; font-weight: 500; + border-bottom: 1px dotted var(--border-strong); } -.prose-docs a:hover { text-decoration: underline; } +.prose-docs a:hover { border-bottom-color: var(--accent-text); } .prose-docs strong { color: var(--fg); font-weight: 600; } .prose-docs ul, .prose-docs ol { margin: 1rem 0; padding-left: 1.5rem; } .prose-docs ul { list-style: disc; } .prose-docs ol { list-style: decimal; } .prose-docs li { margin: 0.375rem 0; } -.prose-docs li::marker { color: var(--fg-subtle); } +.prose-docs li::marker { color: var(--accent-text); } .prose-docs blockquote { - border-left: 3px solid var(--border-strong); + border-left: 3px solid var(--accent); padding-left: 1rem; margin: 1.25rem 0; color: var(--fg-muted); } -.prose-docs hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; } +.prose-docs hr { border: 0; border-top: 1px dashed var(--border-strong); margin: 2rem 0; } /* inline code */ .prose-docs :not(pre) > code { font-size: 0.85em; background-color: var(--bg-inset); border: 1px solid var(--border); - border-radius: 0.375rem; + border-radius: 0.25rem; padding: 0.1rem 0.35rem; color: var(--fg); } @@ -164,7 +222,7 @@ code, pre, kbd { .prose-docs pre { background-color: var(--bg-subtle); border: 1px solid var(--border); - border-radius: 0.625rem; + border-radius: 0.5rem; padding: 1rem; overflow-x: auto; margin: 1.25rem 0; @@ -183,7 +241,13 @@ code, pre, kbd { padding: 0.5rem 0.75rem; text-align: left; } -.prose-docs th { background-color: var(--bg-subtle); color: var(--fg); font-weight: 600; } +.prose-docs th { + background-color: var(--bg-subtle); + color: var(--fg); + font-weight: 600; + font-family: var(--font-mono); + font-size: 0.8125rem; +} /* Page-enter fade for route transitions */ .page-enter-active, .page-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; } diff --git a/docs/app/components/DocsBadge.vue b/docs/app/components/DocsBadge.vue index 321b308..24c81af 100644 --- a/docs/app/components/DocsBadge.vue +++ b/docs/app/components/DocsBadge.vue @@ -3,17 +3,8 @@ size?: 'sm' | 'md'; }>(); -const kindColors: Record = { - function: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300', - class: 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300', - interface: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300', - type: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300', - enum: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300', - variable: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-500/15 dark:text-zinc-300', - component: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300', - guide: 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300', -}; - +// Monochrome instrument badges: the kind reads from the glyph, not a color. +// Components are the one structural exception and carry the accent. const kindLabels: Record = { function: 'fn', class: 'C', @@ -29,8 +20,10 @@ const kindLabels: Record = {