@hraness/design-kit 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARTICLE_COPY.md +208 -0
- package/HERO_FIELDS.md +55 -0
- package/ICONS.md +56 -0
- package/LANTERN_MATERIAL.md +60 -0
- package/LICENSE +21 -0
- package/MARKETING_COPY.md +49 -0
- package/MARKETING_PRESET.md +108 -0
- package/PALETTES.md +90 -0
- package/PAPER_THEME.md +128 -0
- package/README.md +745 -0
- package/STATUS_PAGES.md +67 -0
- package/STYLE.md +245 -0
- package/WRITING.md +72 -0
- package/dist/browser/index.js +2345 -0
- package/dist/chunk-44djxk16.js +6872 -0
- package/dist/chunk-5gtx3pza.js +9 -0
- package/dist/chunk-77391vmq.js +454 -0
- package/dist/chunk-7b1586eb.js +1209 -0
- package/dist/chunk-8834fh4n.js +6 -0
- package/dist/chunk-9t8xyqte.js +870 -0
- package/dist/chunk-cejpzyfh.js +446 -0
- package/dist/chunk-eh71jz57.js +429 -0
- package/dist/chunk-evb02bc1.js +516 -0
- package/dist/chunk-h4k7yv6x.js +368 -0
- package/dist/chunk-he8eznb1.js +760 -0
- package/dist/chunk-mr1vdcjq.js +404 -0
- package/dist/chunk-wzvdn8ey.js +116 -0
- package/dist/fonts/nebula-sans/social-fonts.generated.js +53 -0
- package/dist/icons.js +48 -0
- package/dist/index.js +237 -0
- package/dist/launch.js +41 -0
- package/dist/mockups/client.js +422 -0
- package/dist/mockups/index.js +1850 -0
- package/dist/portfolio.js +1549 -0
- package/dist/provider-marks.js +19 -0
- package/dist/react/charts.js +11 -0
- package/dist/react/hero-backdrop.js +8 -0
- package/dist/react/index.js +7252 -0
- package/dist/react/platform-install.js +10 -0
- package/dist/react/server.js +137 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +5392 -0
- package/dist/syntax-highlighting.js +15 -0
- package/dist/testing.js +250 -0
- package/package.json +377 -0
- package/portfolio-inventory.json +31 -0
- package/scripts/check-lantern-material-snapshot.d.mts +26 -0
- package/scripts/check-lantern-material-snapshot.mjs +126 -0
- package/scripts/check-marketing-snapshot.d.mts +12 -0
- package/scripts/check-marketing-snapshot.mjs +58 -0
- package/scripts/lantern-material-snapshot.ts +101 -0
- package/scripts/marketing-textures.ts +48 -0
- package/scripts/paper-theme-snapshot.ts +97 -0
- package/scripts/product-marketing-snapshot.ts +149 -0
- package/src/appearance-menu.css +266 -0
- package/src/appearance.ts +32 -0
- package/src/article-html.ts +269 -0
- package/src/article.ts +686 -0
- package/src/browser/appearance-menu.ts +604 -0
- package/src/browser/artifact-share.ts +164 -0
- package/src/browser/design-palette.ts +197 -0
- package/src/browser/foil.ts +124 -0
- package/src/browser/hero-light.ts +11 -0
- package/src/browser/index.ts +29 -0
- package/src/browser/status-field.ts +432 -0
- package/src/browser/status-page.ts +100 -0
- package/src/browser/sticky-offset.ts +82 -0
- package/src/browser/theme-color-sync.ts +213 -0
- package/src/charts.css +7 -0
- package/src/compiler-components.css +34 -0
- package/src/compiler-foundation.css +18 -0
- package/src/compiler-palettes.css +7 -0
- package/src/compiler-tokens.css +342 -0
- package/src/components.css +3 -0
- package/src/design-gallery.css +431 -0
- package/src/effects.css +14 -0
- package/src/fonts/geist-mono/GeistMono[wght].woff2 +0 -0
- package/src/fonts/geist-mono/OFL.txt +93 -0
- package/src/fonts/geist-mono/PROVENANCE.md +12 -0
- package/src/fonts/instrument-serif/OFL.txt +93 -0
- package/src/fonts/instrument-serif/UPSTREAM.md +14 -0
- package/src/fonts/instrument-serif/instrument-serif-latin-400.woff2 +0 -0
- package/src/fonts/nebula-sans/LICENSE.txt +96 -0
- package/src/fonts/nebula-sans/NebulaSans-Black.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BlackItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Bold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BoldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.otf +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-BookItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Light.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-LightItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Medium.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-MediumItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-Semibold.woff2 +0 -0
- package/src/fonts/nebula-sans/NebulaSans-SemiboldItalic.woff2 +0 -0
- package/src/fonts/nebula-sans/PROVENANCE.md +14 -0
- package/src/fonts/nebula-sans/social-fonts.generated.ts +3873 -0
- package/src/fonts.css +106 -0
- package/src/icons/act60/act60.svg +5 -0
- package/src/icons/act60/application.svg +9 -0
- package/src/icons/act60/chapter-compare.svg +3 -0
- package/src/icons/act60/decree-residence.svg +6 -0
- package/src/icons/act60/estimate.svg +7 -0
- package/src/icons/act60/export-services.svg +4 -0
- package/src/icons/act60/investor.svg +13 -0
- package/src/icons/act60/presence.svg +7 -0
- package/src/icons/aicharts/aicharts.svg +6 -0
- package/src/icons/aicharts/cost-compare.svg +4 -0
- package/src/icons/aicharts/task-audio.svg +7 -0
- package/src/icons/aicharts/task-coding.svg +6 -0
- package/src/icons/aicharts/task-images.svg +3 -0
- package/src/icons/aicharts/task-reasoning.svg +9 -0
- package/src/icons/aicharts/task-research.svg +5 -0
- package/src/icons/aicharts/task-video.svg +4 -0
- package/src/icons/manifest.json +1371 -0
- package/src/icons/platonik/conversation.svg +3 -0
- package/src/icons/platonik/creature.svg +7 -0
- package/src/icons/platonik/economy.svg +4 -0
- package/src/icons/platonik/keep-light.svg +6 -0
- package/src/icons/platonik/platonik.svg +7 -0
- package/src/icons/platonik/spark-route.svg +15 -0
- package/src/icons/receipts/act60-retry.receipt.json +888 -0
- package/src/icons/receipts/act60.receipt.json +1366 -0
- package/src/icons/receipts/aicharts.receipt.json +1546 -0
- package/src/icons/receipts/platonik.receipt.json +1011 -0
- package/src/icons/receipts/roughday.receipt.json +770 -0
- package/src/icons/receipts/shared.receipt.json +1296 -0
- package/src/icons/receipts/slopcamera.receipt.json +1495 -0
- package/src/icons/receipts/soundfish-retry.receipt.json +460 -0
- package/src/icons/receipts/soundfish.receipt.json +1709 -0
- package/src/icons/receipts/sponge.receipt.json +1743 -0
- package/src/icons/receipts/stripe-history-retry.receipt.json +145 -0
- package/src/icons/receipts/stripe-history.receipt.json +1202 -0
- package/src/icons/receipts/wordcell.receipt.json +1924 -0
- package/src/icons/roughday/finance.svg +7 -0
- package/src/icons/roughday/roughday.svg +6 -0
- package/src/icons/roughday/technology.svg +4 -0
- package/src/icons/roughday/world.svg +4 -0
- package/src/icons/sets/act60.json +63 -0
- package/src/icons/sets/aicharts.json +63 -0
- package/src/icons/sets/platonik.json +39 -0
- package/src/icons/sets/roughday.json +25 -0
- package/src/icons/sets/shared.json +12 -0
- package/src/icons/sets/slopcamera.json +47 -0
- package/src/icons/sets/soundfish.json +43 -0
- package/src/icons/sets/sponge.json +51 -0
- package/src/icons/sets/stripe-history.json +39 -0
- package/src/icons/sets/wordcell.json +51 -0
- package/src/icons/shared/agent-skill.svg +4 -0
- package/src/icons/shared/cli.svg +5 -0
- package/src/icons/shared/privacy.svg +3 -0
- package/src/icons/shared/research.svg +7 -0
- package/src/icons/shared/sdk.svg +3 -0
- package/src/icons/slopcamera/diagram.svg +3 -0
- package/src/icons/slopcamera/direct-scene.svg +5 -0
- package/src/icons/slopcamera/edit-video.svg +17 -0
- package/src/icons/slopcamera/mcp.svg +3 -0
- package/src/icons/slopcamera/native-world.svg +3 -0
- package/src/icons/slopcamera/slopcamera.svg +11 -0
- package/src/icons/soundfish/agent-ui.svg +7 -0
- package/src/icons/soundfish/collaborate.svg +5 -0
- package/src/icons/soundfish/compose.svg +3 -0
- package/src/icons/soundfish/hear.svg +6 -0
- package/src/icons/soundfish/human-ui.svg +6 -0
- package/src/icons/soundfish/share.svg +3 -0
- package/src/icons/soundfish/soundfish.svg +6 -0
- package/src/icons/sponge/api.svg +3 -0
- package/src/icons/sponge/documents.svg +3 -0
- package/src/icons/sponge/library.svg +3 -0
- package/src/icons/sponge/one-question.svg +5 -0
- package/src/icons/sponge/people-direct.svg +5 -0
- package/src/icons/sponge/read.svg +3 -0
- package/src/icons/sponge/save.svg +4 -0
- package/src/icons/sponge/sponge.svg +7 -0
- package/src/icons/stripe-history/company-history.svg +3 -0
- package/src/icons/stripe-history/evidence.svg +4 -0
- package/src/icons/stripe-history/independence.svg +7 -0
- package/src/icons/stripe-history/publications.svg +11 -0
- package/src/icons/stripe-history/sources.svg +6 -0
- package/src/icons/stripe-history/stripe-history.svg +3 -0
- package/src/icons/wordcell/backlinks.svg +6 -0
- package/src/icons/wordcell/capture.svg +6 -0
- package/src/icons/wordcell/git-provenance.svg +4 -0
- package/src/icons/wordcell/kb.svg +4 -0
- package/src/icons/wordcell/markdown.svg +4 -0
- package/src/icons/wordcell/scopes.svg +3 -0
- package/src/icons/wordcell/search.svg +7 -0
- package/src/icons.generated.ts +734 -0
- package/src/icons.ts +51 -0
- package/src/index.ts +275 -0
- package/src/lantern-material.css +213 -0
- package/src/launch.ts +624 -0
- package/src/marketing-assets/UPSTREAM.md +18 -0
- package/src/marketing-assets/cells.svg +1 -0
- package/src/marketing-assets/grain.svg +1 -0
- package/src/marketing-forced-colors.css +12 -0
- package/src/mockups/client.tsx +415 -0
- package/src/mockups/core.tsx +318 -0
- package/src/mockups/frames.tsx +368 -0
- package/src/mockups/index.ts +14 -0
- package/src/mockups/surfaces.tsx +681 -0
- package/src/mockups.css +2607 -0
- package/src/palette-appearance.ts +45 -0
- package/src/palette-bridge.css +124 -0
- package/src/palette-color.ts +41 -0
- package/src/palette-system.css +721 -0
- package/src/palette-themes.ts +23 -0
- package/src/palette-tokens.stylex.ts +519 -0
- package/src/palettes.css +3 -0
- package/src/palettes.ts +161 -0
- package/src/paper-theme.css +244 -0
- package/src/plain-publication.css +1241 -0
- package/src/plain-site.css +397 -0
- package/src/platforms.ts +151 -0
- package/src/portfolio.generated.json +1595 -0
- package/src/portfolio.generated.ts +1596 -0
- package/src/portfolio.ts +226 -0
- package/src/product-marketing-foundation.css +433 -0
- package/src/product-marketing-preset.css +261 -0
- package/src/product-marketing.css +2186 -0
- package/src/provider-marks.generated.ts +112 -0
- package/src/provider-marks.ts +143 -0
- package/src/react/animated-rail-stage.stylex.ts +15 -0
- package/src/react/animated-rail-stage.tsx +51 -0
- package/src/react/app-shell.stylex.ts +104 -0
- package/src/react/app-shell.tsx +121 -0
- package/src/react/article.tsx +602 -0
- package/src/react/aurora-dots-background.tsx +34 -0
- package/src/react/charts.stylex.ts +563 -0
- package/src/react/charts.tsx +610 -0
- package/src/react/chat.stylex.ts +34 -0
- package/src/react/chat.tsx +155 -0
- package/src/react/design-gallery.tsx +943 -0
- package/src/react/design-palette.stylex.ts +124 -0
- package/src/react/design-palette.tsx +196 -0
- package/src/react/design-theme-context.tsx +41 -0
- package/src/react/effects.stylex.ts +325 -0
- package/src/react/fader.stylex.ts +71 -0
- package/src/react/fader.tsx +151 -0
- package/src/react/foil-card-math.ts +100 -0
- package/src/react/foil-card-surface.stylex.ts +326 -0
- package/src/react/foil-card-surface.tsx +742 -0
- package/src/react/foil-mark.tsx +35 -0
- package/src/react/foil.stylex.ts +163 -0
- package/src/react/haptics.ts +228 -0
- package/src/react/hero-backdrop.tsx +22 -0
- package/src/react/index.ts +38 -0
- package/src/react/keyboard-shortcuts.ts +228 -0
- package/src/react/lantern-material-gallery.tsx +166 -0
- package/src/react/lantern-material.stylex.ts +39 -0
- package/src/react/launch-beats.tsx +73 -0
- package/src/react/navigation-rail.stylex.ts +130 -0
- package/src/react/navigation-rail.tsx +228 -0
- package/src/react/particle-halo.tsx +115 -0
- package/src/react/phaser-dots.tsx +501 -0
- package/src/react/platform-icons.tsx +94 -0
- package/src/react/platform-install.stylex.ts +385 -0
- package/src/react/platform-install.tsx +389 -0
- package/src/react/playback-transport.stylex.ts +14 -0
- package/src/react/playback-transport.tsx +112 -0
- package/src/react/procedural-backdrop.tsx +192 -0
- package/src/react/procedural-recipe.ts +323 -0
- package/src/react/product-marketing.stylex.ts +4469 -0
- package/src/react/product-marketing.tsx +1611 -0
- package/src/react/production-data-preview-notice.stylex.ts +40 -0
- package/src/react/production-data-preview-notice.tsx +34 -0
- package/src/react/provider-mark.stylex.ts +132 -0
- package/src/react/provider-mark.tsx +120 -0
- package/src/react/relative-time.tsx +117 -0
- package/src/react/route-state.stylex.ts +39 -0
- package/src/react/route-state.tsx +350 -0
- package/src/react/server.ts +11 -0
- package/src/react/social-kit-panel.tsx +103 -0
- package/src/react/sticky-offset.tsx +33 -0
- package/src/react/surfaces.stylex.ts +179 -0
- package/src/react/surfaces.tsx +330 -0
- package/src/react/syntax-code.tsx +33 -0
- package/src/react/theme-color-sync.ts +1 -0
- package/src/react/theme-resolution.ts +11 -0
- package/src/react/theme.stylex.ts +276 -0
- package/src/react/theme.tsx +402 -0
- package/src/reading.css +72 -0
- package/src/relative-time.ts +185 -0
- package/src/reset.css +2 -0
- package/src/site-shell.css +16 -0
- package/src/status-page-html.ts +63 -0
- package/src/status-page.css +276 -0
- package/src/status-page.ts +323 -0
- package/src/styles.css +21 -0
- package/src/syntax-code-lexers.ts +303 -0
- package/src/syntax-highlighting.css +78 -0
- package/src/syntax-highlighting.ts +477 -0
- package/src/syntax-tokens.ts +33 -0
- package/src/testing.ts +307 -0
- package/src/tokens.css +3 -0
- package/src/typography.css +86 -0
- package/vendor/evilcharts/LICENSE +21 -0
- package/vendor/evilcharts/UPSTREAM.md +14 -0
- package/vendor/platform-marks/LICENSE +30 -0
- package/vendor/platform-marks/UPSTREAM.md +15 -0
- package/vendor/platform-marks/apple.svg +1 -0
- package/vendor/platform-marks/linux.svg +1 -0
- package/vendor/provider-marks/CRUSH-LICENSE.md +134 -0
- package/vendor/provider-marks/LICENSE +21 -0
- package/vendor/provider-marks/UPSTREAM.md +27 -0
- package/vendor/provider-marks/aider.svg +3 -0
- package/vendor/provider-marks/crush-heartbit.svg +8 -0
- package/vendor/provider-marks/lobehub/alibabacloud-color.svg +1 -0
- package/vendor/provider-marks/lobehub/alibabacloud.svg +1 -0
- package/vendor/provider-marks/lobehub/amp-color.svg +1 -0
- package/vendor/provider-marks/lobehub/amp.svg +1 -0
- package/vendor/provider-marks/lobehub/anthropic.svg +1 -0
- package/vendor/provider-marks/lobehub/claude-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claude.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode-color.svg +1 -0
- package/vendor/provider-marks/lobehub/claudecode.svg +1 -0
- package/vendor/provider-marks/lobehub/codex-color.svg +1 -0
- package/vendor/provider-marks/lobehub/codex.svg +1 -0
- package/vendor/provider-marks/lobehub/cursor.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek-color.svg +1 -0
- package/vendor/provider-marks/lobehub/deepseek.svg +1 -0
- package/vendor/provider-marks/lobehub/devin-color.svg +1 -0
- package/vendor/provider-marks/lobehub/devin.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini-color.svg +1 -0
- package/vendor/provider-marks/lobehub/gemini.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli-color.svg +1 -0
- package/vendor/provider-marks/lobehub/geminicli.svg +1 -0
- package/vendor/provider-marks/lobehub/goose.svg +1 -0
- package/vendor/provider-marks/lobehub/meta-color.svg +1 -0
- package/vendor/provider-marks/lobehub/meta.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral-color.svg +1 -0
- package/vendor/provider-marks/lobehub/mistral.svg +1 -0
- package/vendor/provider-marks/lobehub/moonshot.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia-color.svg +1 -0
- package/vendor/provider-marks/lobehub/nvidia.svg +1 -0
- package/vendor/provider-marks/lobehub/openai.svg +1 -0
- package/vendor/provider-marks/lobehub/opencode.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity-color.svg +1 -0
- package/vendor/provider-marks/lobehub/perplexity.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen-color.svg +1 -0
- package/vendor/provider-marks/lobehub/qwen.svg +1 -0
- package/vendor/provider-marks/lobehub/xai.svg +1 -0
- package/vendor/provider-marks/lobehub/zai.svg +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AppearanceIcon,
|
|
5
|
+
IconButton,
|
|
6
|
+
Menu,
|
|
7
|
+
MenuItem,
|
|
8
|
+
MenuTrigger,
|
|
9
|
+
SegmentedControl,
|
|
10
|
+
cn,
|
|
11
|
+
type SegmentedItem,
|
|
12
|
+
} from "@hraness/ui";
|
|
13
|
+
import * as stylex from "@stylexjs/stylex";
|
|
14
|
+
import { ThemeProvider as NextThemeProvider, useTheme } from "next-themes";
|
|
15
|
+
import {
|
|
16
|
+
type ReactNode,
|
|
17
|
+
useEffect,
|
|
18
|
+
useRef,
|
|
19
|
+
useSyncExternalStore,
|
|
20
|
+
} from "react";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type ConcreteDesignTheme,
|
|
24
|
+
defaultDesignTheme,
|
|
25
|
+
type DesignTheme,
|
|
26
|
+
type DesignThemeLabels,
|
|
27
|
+
designThemeLabel,
|
|
28
|
+
designThemeStorageKey,
|
|
29
|
+
designThemes,
|
|
30
|
+
isDesignTheme,
|
|
31
|
+
normalizeDesignTheme,
|
|
32
|
+
} from "../appearance.js";
|
|
33
|
+
import { colors } from "../index.js";
|
|
34
|
+
import { DesignPaletteMenuButton, useDesignPalette } from "./design-palette.js";
|
|
35
|
+
import {
|
|
36
|
+
DesignPortalThemeProvider,
|
|
37
|
+
useDesignPortalClassName,
|
|
38
|
+
} from "./design-theme-context.js";
|
|
39
|
+
import { resolveEffectiveTheme } from "./theme-resolution.js";
|
|
40
|
+
import { themeStyles } from "./theme.stylex.js";
|
|
41
|
+
import {
|
|
42
|
+
acquireThemeColorMeta,
|
|
43
|
+
type ThemeColorMetaRegistration,
|
|
44
|
+
} from "./theme-color-sync.js";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
type ConcreteDesignTheme,
|
|
48
|
+
defaultDesignTheme,
|
|
49
|
+
type DesignTheme,
|
|
50
|
+
designThemeStorageKey,
|
|
51
|
+
designThemes,
|
|
52
|
+
isDesignTheme,
|
|
53
|
+
normalizeDesignTheme,
|
|
54
|
+
} from "../appearance.js";
|
|
55
|
+
|
|
56
|
+
const concreteThemes = ["light", "dark"] as const;
|
|
57
|
+
const emptySubscribe = (): (() => void) => () => undefined;
|
|
58
|
+
|
|
59
|
+
function useHydrated(): boolean {
|
|
60
|
+
return useSyncExternalStore(emptySubscribe, () => true, () => false);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function themeStorageGuardScript(storageKey: string): string {
|
|
64
|
+
const serializedKey = JSON.stringify(storageKey)
|
|
65
|
+
.replaceAll("<", "\\u003c")
|
|
66
|
+
.replaceAll("\u2028", "\\u2028")
|
|
67
|
+
.replaceAll("\u2029", "\\u2029");
|
|
68
|
+
return `(()=>{try{const key=${serializedKey};const value=localStorage.getItem(key);if(value!==null&&value!=="light"&&value!=="dark"&&value!=="system")localStorage.setItem(key,"${defaultDesignTheme}")}catch{}})();`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function PersistedThemeNormalizer() {
|
|
72
|
+
const { setTheme, theme } = useTheme();
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (theme !== undefined && !isDesignTheme(theme)) setTheme(defaultDesignTheme);
|
|
76
|
+
}, [setTheme, theme]);
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function PortalThemeBridge({
|
|
82
|
+
children,
|
|
83
|
+
forcedTheme,
|
|
84
|
+
}: Readonly<{
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
forcedTheme: ConcreteDesignTheme | undefined;
|
|
87
|
+
}>) {
|
|
88
|
+
const { resolvedTheme } = useTheme();
|
|
89
|
+
const portalTheme = resolveEffectiveTheme(forcedTheme, resolvedTheme);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<DesignPortalThemeProvider theme={portalTheme}>
|
|
93
|
+
{children}
|
|
94
|
+
</DesignPortalThemeProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DesignThemeProviderProps {
|
|
99
|
+
readonly children: ReactNode;
|
|
100
|
+
/** Locks products with an explicit single-theme identity to one concrete theme. */
|
|
101
|
+
readonly forcedTheme?: ConcreteDesignTheme;
|
|
102
|
+
/** Applied to next-themes' blocking bootstrap script for strict CSPs. */
|
|
103
|
+
readonly nonce?: string;
|
|
104
|
+
/** Defaults to the shared, versioned browser preference key. */
|
|
105
|
+
readonly storageKey?: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Shared appearance boundary for browser products. System is the first-visit
|
|
110
|
+
* preference, while explicit Light, Dark, and System choices persist.
|
|
111
|
+
*/
|
|
112
|
+
export function DesignThemeProvider({
|
|
113
|
+
children,
|
|
114
|
+
forcedTheme,
|
|
115
|
+
nonce,
|
|
116
|
+
storageKey = designThemeStorageKey,
|
|
117
|
+
}: DesignThemeProviderProps) {
|
|
118
|
+
return (
|
|
119
|
+
<>
|
|
120
|
+
{forcedTheme === undefined ? (
|
|
121
|
+
<script
|
|
122
|
+
{...(nonce === undefined ? {} : { nonce })}
|
|
123
|
+
data-hraness-design-theme-guard=""
|
|
124
|
+
dangerouslySetInnerHTML={{ __html: themeStorageGuardScript(storageKey) }}
|
|
125
|
+
suppressHydrationWarning
|
|
126
|
+
/>
|
|
127
|
+
) : null}
|
|
128
|
+
<NextThemeProvider
|
|
129
|
+
{...(nonce === undefined ? {} : { nonce })}
|
|
130
|
+
attribute="data-theme"
|
|
131
|
+
defaultTheme={forcedTheme ?? defaultDesignTheme}
|
|
132
|
+
disableTransitionOnChange
|
|
133
|
+
// next-themes captures this flag when its applying callback is created.
|
|
134
|
+
// Keep System resolvable after unforcing; forcedTheme still wins while set.
|
|
135
|
+
enableSystem
|
|
136
|
+
forcedTheme={forcedTheme}
|
|
137
|
+
storageKey={storageKey}
|
|
138
|
+
themes={[...concreteThemes]}
|
|
139
|
+
>
|
|
140
|
+
{forcedTheme === undefined ? <PersistedThemeNormalizer /> : null}
|
|
141
|
+
<PortalThemeBridge forcedTheme={forcedTheme}>{children}</PortalThemeBridge>
|
|
142
|
+
</NextThemeProvider>
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type ThemeToggleLabels = DesignThemeLabels;
|
|
148
|
+
export type ThemeToggleDisplay = "icons" | "labels";
|
|
149
|
+
export type ThemeTogglePresentation = "menu" | "segmented";
|
|
150
|
+
|
|
151
|
+
function themeToggleLabel(id: DesignTheme, labels?: ThemeToggleLabels): string {
|
|
152
|
+
return designThemeLabel(id, labels);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function themeToggleItems(
|
|
156
|
+
labels?: ThemeToggleLabels,
|
|
157
|
+
): readonly [SegmentedItem<DesignTheme>, ...SegmentedItem<DesignTheme>[]] {
|
|
158
|
+
return [
|
|
159
|
+
{ id: "light", label: themeToggleLabel("light", labels) },
|
|
160
|
+
{ id: "dark", label: themeToggleLabel("dark", labels) },
|
|
161
|
+
{ id: "system", label: themeToggleLabel("system", labels) },
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function themeToggleIcon(id: DesignTheme): ReactNode {
|
|
166
|
+
return <AppearanceIcon name={id} />;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function themeToggleIconItems(
|
|
170
|
+
labels?: ThemeToggleLabels,
|
|
171
|
+
): readonly [SegmentedItem<DesignTheme>, ...SegmentedItem<DesignTheme>[]] {
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
ariaLabel: themeToggleLabel("light", labels),
|
|
175
|
+
id: "light",
|
|
176
|
+
label: themeToggleIcon("light"),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
ariaLabel: themeToggleLabel("dark", labels),
|
|
180
|
+
id: "dark",
|
|
181
|
+
label: themeToggleIcon("dark"),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
ariaLabel: themeToggleLabel("system", labels),
|
|
185
|
+
id: "system",
|
|
186
|
+
label: themeToggleIcon("system"),
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface ThemeToggleBaseProps {
|
|
192
|
+
readonly "aria-label"?: string;
|
|
193
|
+
readonly className?: string;
|
|
194
|
+
readonly labels?: ThemeToggleLabels;
|
|
195
|
+
readonly size?: "compact" | "default";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type ThemeTogglePresentationProps =
|
|
199
|
+
| {
|
|
200
|
+
/** Three visible choices for settings and other surfaces with stable inline room. */
|
|
201
|
+
readonly display?: ThemeToggleDisplay;
|
|
202
|
+
readonly presentation?: "segmented";
|
|
203
|
+
}
|
|
204
|
+
| {
|
|
205
|
+
/** One bounded trigger for persistent chrome and text-enlargement reflow. */
|
|
206
|
+
readonly display?: never;
|
|
207
|
+
readonly presentation: "menu";
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
type ThemeToggleControlProps =
|
|
211
|
+
| {
|
|
212
|
+
readonly onChange?: never;
|
|
213
|
+
readonly value?: never;
|
|
214
|
+
}
|
|
215
|
+
| {
|
|
216
|
+
readonly onChange: (theme: DesignTheme) => void;
|
|
217
|
+
readonly value: DesignTheme;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type ThemeToggleProps =
|
|
221
|
+
& ThemeToggleBaseProps
|
|
222
|
+
& ThemeToggleControlProps
|
|
223
|
+
& ThemeTogglePresentationProps;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A hydration-stable, persisted Light/Dark/System appearance control.
|
|
227
|
+
* Persistent product chrome defaults to the compact menu presentation.
|
|
228
|
+
*/
|
|
229
|
+
export function ThemeToggle({
|
|
230
|
+
"aria-label": ariaLabel = "Appearance",
|
|
231
|
+
className,
|
|
232
|
+
display,
|
|
233
|
+
labels,
|
|
234
|
+
onChange,
|
|
235
|
+
presentation,
|
|
236
|
+
size = "compact",
|
|
237
|
+
value: controlledValue,
|
|
238
|
+
}: ThemeToggleProps) {
|
|
239
|
+
const portalClassName = useDesignPortalClassName();
|
|
240
|
+
const hydrated = useHydrated();
|
|
241
|
+
const { setTheme, theme } = useTheme();
|
|
242
|
+
const controlled = controlledValue !== undefined;
|
|
243
|
+
const ready = controlled || hydrated;
|
|
244
|
+
const value = controlledValue ?? (hydrated ? normalizeDesignTheme(theme) : defaultDesignTheme);
|
|
245
|
+
const resolvedPresentation = presentation ?? (display === undefined ? "menu" : "segmented");
|
|
246
|
+
const resolvedDisplay = display ?? "icons";
|
|
247
|
+
const items = resolvedDisplay === "icons" ? themeToggleIconItems(labels) : themeToggleItems(labels);
|
|
248
|
+
const presentationStyles = stylex.props(
|
|
249
|
+
themeStyles.root,
|
|
250
|
+
resolvedPresentation === "menu" && themeStyles.menuRoot,
|
|
251
|
+
!ready && themeStyles.notReady,
|
|
252
|
+
);
|
|
253
|
+
const changeTheme = (nextTheme: DesignTheme): void => {
|
|
254
|
+
if (controlled) onChange?.(nextTheme);
|
|
255
|
+
else setTheme(nextTheme);
|
|
256
|
+
};
|
|
257
|
+
const currentLabel = themeToggleLabel(value, labels);
|
|
258
|
+
|
|
259
|
+
return (
|
|
260
|
+
<div
|
|
261
|
+
{...presentationStyles}
|
|
262
|
+
aria-busy={!ready || undefined}
|
|
263
|
+
className={cn(
|
|
264
|
+
"hraness-design-theme-toggle",
|
|
265
|
+
presentationStyles.className,
|
|
266
|
+
className,
|
|
267
|
+
)}
|
|
268
|
+
data-display={resolvedPresentation === "menu" ? "icons" : resolvedDisplay}
|
|
269
|
+
data-hraness-appearance-menu={resolvedPresentation === "menu" ? "" : undefined}
|
|
270
|
+
data-hraness-theme-toggle-stylex=""
|
|
271
|
+
data-presentation={resolvedPresentation}
|
|
272
|
+
data-ready={ready ? "true" : "false"}
|
|
273
|
+
data-theme-value={value}
|
|
274
|
+
>
|
|
275
|
+
{resolvedPresentation === "menu" ? (
|
|
276
|
+
<MenuTrigger>
|
|
277
|
+
<IconButton
|
|
278
|
+
aria-label={`${ariaLabel}: ${currentLabel}`}
|
|
279
|
+
controlClassName="hraness-design-theme-toggle__trigger"
|
|
280
|
+
controlXstyle={themeStyles.trigger}
|
|
281
|
+
isDisabled={!ready}
|
|
282
|
+
size={size}
|
|
283
|
+
tooltip={`${ariaLabel}: ${currentLabel}`}
|
|
284
|
+
>
|
|
285
|
+
{themeToggleIcon(value)}
|
|
286
|
+
</IconButton>
|
|
287
|
+
<Menu
|
|
288
|
+
aria-label={ariaLabel}
|
|
289
|
+
className="hraness-design-theme-toggle__menu"
|
|
290
|
+
disallowEmptySelection
|
|
291
|
+
onAction={(key) => {
|
|
292
|
+
if (isDesignTheme(key)) changeTheme(key);
|
|
293
|
+
}}
|
|
294
|
+
popoverClassName={cn(
|
|
295
|
+
"hraness-design-theme-toggle__popover",
|
|
296
|
+
portalClassName,
|
|
297
|
+
)}
|
|
298
|
+
popoverXstyle={themeStyles.popover}
|
|
299
|
+
selectedKeys={[value]}
|
|
300
|
+
selectionMode="single"
|
|
301
|
+
xstyle={themeStyles.menu}
|
|
302
|
+
>
|
|
303
|
+
{designThemes.map((id) => (
|
|
304
|
+
<MenuItem
|
|
305
|
+
className="hraness-design-theme-toggle__item"
|
|
306
|
+
data-theme-value={id}
|
|
307
|
+
id={id}
|
|
308
|
+
key={id}
|
|
309
|
+
leading={themeToggleIcon(id)}
|
|
310
|
+
textValue={themeToggleLabel(id, labels)}
|
|
311
|
+
xstyle={[
|
|
312
|
+
themeStyles.item,
|
|
313
|
+
id === value && themeStyles.itemSelected,
|
|
314
|
+
]}
|
|
315
|
+
>
|
|
316
|
+
{themeToggleLabel(id, labels)}
|
|
317
|
+
</MenuItem>
|
|
318
|
+
))}
|
|
319
|
+
</Menu>
|
|
320
|
+
</MenuTrigger>
|
|
321
|
+
) : (
|
|
322
|
+
<SegmentedControl
|
|
323
|
+
aria-label={ariaLabel}
|
|
324
|
+
isDisabled={!ready}
|
|
325
|
+
items={items}
|
|
326
|
+
onChange={changeTheme}
|
|
327
|
+
size={size}
|
|
328
|
+
value={value}
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export type ThemeMenuButtonProps = ThemeToggleBaseProps & ThemeToggleControlProps;
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The canonical persistent appearance selector. Render it as the final action
|
|
339
|
+
* in a product header so every surface exposes the same icon-menu pattern.
|
|
340
|
+
*/
|
|
341
|
+
export function ThemeMenuButton(props: ThemeMenuButtonProps) {
|
|
342
|
+
const palette = useDesignPalette();
|
|
343
|
+
if (palette !== null) return <DesignPaletteMenuButton {...props} />;
|
|
344
|
+
return <ThemeToggle {...props} presentation="menu" />;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface ThemeColorSyncProps {
|
|
348
|
+
readonly darkColor?: string;
|
|
349
|
+
readonly lightColor?: string;
|
|
350
|
+
readonly metaName?: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function themeColorFor(
|
|
354
|
+
resolvedTheme: string | undefined,
|
|
355
|
+
values: Readonly<{ dark: string; light: string }>,
|
|
356
|
+
): string {
|
|
357
|
+
return resolvedTheme === "dark" ? values.dark : values.light;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Keeps browser and installed-app chrome aligned with forced or resolved appearance. */
|
|
361
|
+
export function ThemeColorSync({
|
|
362
|
+
darkColor = colors.dark.background,
|
|
363
|
+
lightColor = colors.light.background,
|
|
364
|
+
metaName = "theme-color",
|
|
365
|
+
}: ThemeColorSyncProps) {
|
|
366
|
+
const palette = useDesignPalette();
|
|
367
|
+
const { forcedTheme, resolvedTheme } = useTheme();
|
|
368
|
+
const effectiveTheme = resolveEffectiveTheme(forcedTheme, resolvedTheme);
|
|
369
|
+
const registrationId = useRef(Symbol("hraness-design-theme-color"));
|
|
370
|
+
const registration = useRef<ThemeColorMetaRegistration | null>(null);
|
|
371
|
+
const resolvedColor = palette !== null
|
|
372
|
+
// The adopted palette controller already owns theme-color, including when
|
|
373
|
+
// its bootstrap was loaded as a separate bundle before React.
|
|
374
|
+
? (metaName !== "theme-color" && palette.ready ? palette.background : undefined)
|
|
375
|
+
: effectiveTheme !== undefined
|
|
376
|
+
? themeColorFor(effectiveTheme, { dark: darkColor, light: lightColor })
|
|
377
|
+
: undefined;
|
|
378
|
+
const hasResolvedColor = resolvedColor !== undefined;
|
|
379
|
+
const latestColor = useRef(resolvedColor);
|
|
380
|
+
latestColor.current = resolvedColor;
|
|
381
|
+
|
|
382
|
+
useEffect(() => {
|
|
383
|
+
if (!hasResolvedColor || latestColor.current === undefined) return;
|
|
384
|
+
const current = acquireThemeColorMeta(
|
|
385
|
+
document,
|
|
386
|
+
metaName,
|
|
387
|
+
registrationId.current,
|
|
388
|
+
latestColor.current,
|
|
389
|
+
);
|
|
390
|
+
registration.current = current;
|
|
391
|
+
return () => {
|
|
392
|
+
if (registration.current === current) registration.current = null;
|
|
393
|
+
current.release();
|
|
394
|
+
};
|
|
395
|
+
}, [hasResolvedColor, metaName]);
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
if (resolvedColor !== undefined) registration.current?.update(resolvedColor);
|
|
399
|
+
}, [resolvedColor]);
|
|
400
|
+
|
|
401
|
+
return null;
|
|
402
|
+
}
|
package/src/reading.css
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Shared reading scale.
|
|
3
|
+
*
|
|
4
|
+
* `.hraness-prose` is the opt-in long-form reading grammar: docs, guides,
|
|
5
|
+
* methodology pages and article surfaces that do not run the full
|
|
6
|
+
* plain-publication shell. Put the class on the prose container; the shared
|
|
7
|
+
* `--hraness-type-*` tokens in typography.css carry the scale, so a site can
|
|
8
|
+
* retune it in one place.
|
|
9
|
+
*
|
|
10
|
+
* Serif display faces opt in with `data-hraness-reading-face="serif"` on the
|
|
11
|
+
* prose container or an ancestor: their smaller x-height reads ~15% smaller at
|
|
12
|
+
* the same font-size and they carry no bold cut, so the display scale steps up
|
|
13
|
+
* and the display weight drops to the face's single cut. h3 and below stay in
|
|
14
|
+
* the text face (see typography.css), so they keep the text scale.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
:where([data-hraness-reading-face="serif"]) {
|
|
18
|
+
--hraness-type-h1-size: clamp(2.5rem, 2rem + 1.8vw, 3.375rem);
|
|
19
|
+
--hraness-type-h2-size: clamp(1.875rem, 1.55rem + 1vw, 2.375rem);
|
|
20
|
+
--hraness-type-heading-weight: 400;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.hraness-prose {
|
|
24
|
+
max-inline-size: var(--hraness-type-reading-measure, 70ch);
|
|
25
|
+
font-size: var(--hraness-type-reading-size, clamp(1rem, .95rem + .25vw, 1.0625rem));
|
|
26
|
+
line-height: var(--hraness-type-reading-leading, 1.7);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.hraness-prose :is(h1, h2) {
|
|
30
|
+
font-family: var(--hraness-type-heading-font, var(--font-heading, var(--font-text, inherit)));
|
|
31
|
+
font-weight: var(--hraness-type-heading-weight, 600);
|
|
32
|
+
text-wrap: balance;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.hraness-prose h3 {
|
|
36
|
+
font-family: var(--hraness-type-h3-font, var(--font-text, inherit));
|
|
37
|
+
font-weight: var(--hraness-type-h3-weight, 650);
|
|
38
|
+
text-wrap: balance;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.hraness-prose h1 {
|
|
42
|
+
margin: 0 0 1rem;
|
|
43
|
+
font-size: var(--hraness-type-h1-size, clamp(2.25rem, 1.8rem + 1.6vw, 3rem));
|
|
44
|
+
line-height: 1.08;
|
|
45
|
+
letter-spacing: -.025em;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.hraness-prose h2 {
|
|
49
|
+
margin: var(--hraness-type-section-space, 3rem) 0 .6em;
|
|
50
|
+
font-size: var(--hraness-type-h2-size, clamp(1.75rem, 1.45rem + .9vw, 2.125rem));
|
|
51
|
+
line-height: 1.16;
|
|
52
|
+
letter-spacing: -.018em;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.hraness-prose h3 {
|
|
56
|
+
margin: var(--hraness-type-subsection-space, 2rem) 0 .5em;
|
|
57
|
+
font-size: var(--hraness-type-h3-size, clamp(1.25rem, 1.15rem + .4vw, 1.4375rem));
|
|
58
|
+
line-height: 1.28;
|
|
59
|
+
letter-spacing: -.01em;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.hraness-prose :is(h4, h5, h6) {
|
|
63
|
+
margin: 1.6rem 0 .4em;
|
|
64
|
+
font-family: var(--hraness-type-h4-font, var(--font-text, inherit));
|
|
65
|
+
font-size: var(--hraness-type-h4-size, 1.0625rem);
|
|
66
|
+
font-weight: var(--hraness-type-h4-weight, 650);
|
|
67
|
+
line-height: 1.4;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.hraness-prose :is(h1, h2, h3, h4) + :is(h2, h3, h4) {
|
|
71
|
+
margin-block-start: .75rem;
|
|
72
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral relative time. Every input is parsed from `unknown` and
|
|
3
|
+
* rejected before formatting, so an invalid timestamp never renders as text.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type RelativeTimeInput = Date | number | string;
|
|
7
|
+
export type RelativeTimeReference = Date | number;
|
|
8
|
+
export type RelativeTimeNumeric = "auto" | "always";
|
|
9
|
+
export type RelativeTimeUnit =
|
|
10
|
+
| "second"
|
|
11
|
+
| "minute"
|
|
12
|
+
| "hour"
|
|
13
|
+
| "day"
|
|
14
|
+
| "week"
|
|
15
|
+
| "month"
|
|
16
|
+
| "year";
|
|
17
|
+
|
|
18
|
+
export interface RelativeTimeOptions {
|
|
19
|
+
/** Reference instant. Defaults to the current clock. */
|
|
20
|
+
readonly now?: RelativeTimeReference;
|
|
21
|
+
/** BCP 47 locale. Defaults to the runtime locale. */
|
|
22
|
+
readonly locale?: string;
|
|
23
|
+
/** `auto` allows phrases such as "now" and "yesterday". Defaults to `auto`. */
|
|
24
|
+
readonly numeric?: RelativeTimeNumeric;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A signed whole amount of one unit. Negative values are in the past. */
|
|
28
|
+
export interface RelativeTimeValue {
|
|
29
|
+
readonly unit: RelativeTimeUnit;
|
|
30
|
+
readonly value: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const second = 1_000;
|
|
34
|
+
const minute = 60 * second;
|
|
35
|
+
const hour = 60 * minute;
|
|
36
|
+
const day = 24 * hour;
|
|
37
|
+
const week = 7 * day;
|
|
38
|
+
const year = 365.2425 * day;
|
|
39
|
+
const month = year / 12;
|
|
40
|
+
|
|
41
|
+
interface RelativeTimeBucket {
|
|
42
|
+
readonly unit: RelativeTimeUnit;
|
|
43
|
+
readonly milliseconds: number;
|
|
44
|
+
/** The first rounded amount that moves to the next unit. */
|
|
45
|
+
readonly limit: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const buckets: readonly RelativeTimeBucket[] = [
|
|
49
|
+
{ unit: "second", milliseconds: second, limit: 60 },
|
|
50
|
+
{ unit: "minute", milliseconds: minute, limit: 60 },
|
|
51
|
+
{ unit: "hour", milliseconds: hour, limit: 24 },
|
|
52
|
+
{ unit: "day", milliseconds: day, limit: 7 },
|
|
53
|
+
{ unit: "week", milliseconds: week, limit: 4 },
|
|
54
|
+
{ unit: "month", milliseconds: month, limit: 12 },
|
|
55
|
+
{ unit: "year", milliseconds: year, limit: Number.POSITIVE_INFINITY },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Units from finest to coarsest, in the order `resolveRelativeTime` tries them. */
|
|
59
|
+
export const relativeTimeUnits: readonly RelativeTimeUnit[] = Object.freeze(
|
|
60
|
+
buckets.map((bucket) => bucket.unit),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/** ECMAScript's representable time range, in milliseconds either side of the epoch. */
|
|
64
|
+
const maximumTime = 8.64e15;
|
|
65
|
+
const isoDate = /^(\d{4})-(\d{2})-(\d{2})$/u;
|
|
66
|
+
const isoDateTime =
|
|
67
|
+
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})$/u;
|
|
68
|
+
|
|
69
|
+
function describe(value: unknown): string {
|
|
70
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
71
|
+
if (value instanceof Date) return "an invalid Date";
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fromTime(time: number, label: string, original: unknown): Date {
|
|
76
|
+
if (!Number.isFinite(time) || Math.abs(time) > maximumTime) {
|
|
77
|
+
throw new RangeError(`${label} must be a valid instant; received ${describe(original)}.`);
|
|
78
|
+
}
|
|
79
|
+
return new Date(time);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseIsoString(value: string, label: string): Date {
|
|
83
|
+
const date = isoDate.exec(value);
|
|
84
|
+
const dateTime = date === null ? isoDateTime.exec(value) : null;
|
|
85
|
+
const parts = date ?? dateTime;
|
|
86
|
+
if (parts === null) {
|
|
87
|
+
throw new RangeError(
|
|
88
|
+
`${label} must be an ISO 8601 date (YYYY-MM-DD) or a date-time with Z or an offset; received ${describe(value)}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const [yearText, monthText, dayText, hourText = "00", minuteText = "00", secondText = "00", fraction = "", zone = "Z"] =
|
|
92
|
+
parts.slice(1);
|
|
93
|
+
const fields = [yearText, monthText, dayText, hourText, minuteText, secondText].map(Number);
|
|
94
|
+
const [y, mo, d, h, mi, s] = fields as [number, number, number, number, number, number];
|
|
95
|
+
const offsetHours = zone === "Z" ? 0 : Number(zone.slice(1, 3));
|
|
96
|
+
const offsetRest = zone === "Z" ? 0 : Number(zone.slice(4, 6));
|
|
97
|
+
const offsetMinutes = (zone.startsWith("-") ? -1 : 1) * (offsetHours * 60 + offsetRest);
|
|
98
|
+
const milliseconds = Number(fraction.padEnd(3, "0").slice(0, 3));
|
|
99
|
+
const wall = new Date(0);
|
|
100
|
+
wall.setUTCFullYear(y, mo - 1, d);
|
|
101
|
+
wall.setUTCHours(h, mi, s, milliseconds);
|
|
102
|
+
const valid = h < 24 && mi < 60 && s < 60 && offsetHours < 24 && offsetRest < 60
|
|
103
|
+
&& wall.getUTCFullYear() === y && wall.getUTCMonth() === mo - 1 && wall.getUTCDate() === d;
|
|
104
|
+
if (!valid) throw new RangeError(`${label} must name a real calendar instant; received ${describe(value)}.`);
|
|
105
|
+
return fromTime(wall.getTime() - offsetMinutes * minute, label, value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a timestamp from a Date, epoch milliseconds, or an ISO 8601 string.
|
|
110
|
+
* A date-only string is midnight UTC. A date-time string must carry `Z` or an
|
|
111
|
+
* offset, so the server and the browser resolve it to the same instant.
|
|
112
|
+
*/
|
|
113
|
+
export function parseRelativeTimeInput(value: unknown, label = "time"): Date {
|
|
114
|
+
if (value instanceof Date) return fromTime(value.getTime(), label, value);
|
|
115
|
+
if (typeof value === "number") return fromTime(value, label, value);
|
|
116
|
+
if (typeof value === "string") return parseIsoString(value.trim(), label);
|
|
117
|
+
throw new TypeError(`${label} must be a Date, epoch milliseconds, or an ISO 8601 string.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseReference(value: unknown): Date {
|
|
121
|
+
if (value === undefined) return new Date();
|
|
122
|
+
if (value instanceof Date || typeof value === "number") return parseRelativeTimeInput(value, "now");
|
|
123
|
+
throw new TypeError("now must be a Date or epoch milliseconds.");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseNumeric(value: unknown): RelativeTimeNumeric {
|
|
127
|
+
if (value === undefined) return "auto";
|
|
128
|
+
if (value === "auto" || value === "always") return value;
|
|
129
|
+
throw new TypeError(`numeric must be "auto" or "always"; received ${describe(value)}.`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseLocale(value: unknown): string | undefined {
|
|
133
|
+
if (value === undefined) return undefined;
|
|
134
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
135
|
+
throw new TypeError("locale must be a non-empty BCP 47 language tag.");
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Choose the unit and signed amount for a difference of `target - now`.
|
|
142
|
+
* Amounts round half away from zero, so `resolveRelativeTime(-x)` is always
|
|
143
|
+
* the exact negation of `resolveRelativeTime(x)`, including a signed zero.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveRelativeTime(differenceMilliseconds: number): RelativeTimeValue {
|
|
146
|
+
if (!Number.isFinite(differenceMilliseconds)) {
|
|
147
|
+
throw new RangeError("The time difference must be a finite number of milliseconds.");
|
|
148
|
+
}
|
|
149
|
+
const magnitude = Math.abs(differenceMilliseconds);
|
|
150
|
+
const sign = differenceMilliseconds < 0 || Object.is(differenceMilliseconds, -0) ? -1 : 1;
|
|
151
|
+
for (const bucket of buckets) {
|
|
152
|
+
const amount = Math.round(magnitude / bucket.milliseconds);
|
|
153
|
+
if (amount < bucket.limit) {
|
|
154
|
+
// A past instant under half a second keeps a signed zero: "0 seconds ago".
|
|
155
|
+
return { unit: bucket.unit, value: sign * amount };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
throw new RangeError("No relative time unit matched.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const formatters = new Map<string, Intl.RelativeTimeFormat>();
|
|
162
|
+
|
|
163
|
+
function formatterFor(locale: string | undefined, numeric: RelativeTimeNumeric): Intl.RelativeTimeFormat {
|
|
164
|
+
const key = `${locale ?? ""}\u0000${numeric}`;
|
|
165
|
+
let formatter = formatters.get(key);
|
|
166
|
+
if (formatter === undefined) {
|
|
167
|
+
formatter = new Intl.RelativeTimeFormat(locale, { numeric, style: "long" });
|
|
168
|
+
formatters.set(key, formatter);
|
|
169
|
+
}
|
|
170
|
+
return formatter;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Format `target` relative to `now`, for example "in 23 hours",
|
|
175
|
+
* "5 minutes ago" or "now". Throws on an invalid target, reference, locale or
|
|
176
|
+
* numeric option instead of rendering "Invalid Date".
|
|
177
|
+
*/
|
|
178
|
+
export function formatRelativeTime(target: RelativeTimeInput, options: RelativeTimeOptions = {}): string {
|
|
179
|
+
const instant = parseRelativeTimeInput(target, "target");
|
|
180
|
+
const reference = parseReference(options.now);
|
|
181
|
+
const numeric = parseNumeric(options.numeric);
|
|
182
|
+
const locale = parseLocale(options.locale);
|
|
183
|
+
const { unit, value } = resolveRelativeTime(instant.getTime() - reference.getTime());
|
|
184
|
+
return formatterFor(locale, numeric).format(value, unit);
|
|
185
|
+
}
|
package/src/reset.css
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Viewport-height document layout. Nested application and gallery roots opt in. */
|
|
2
|
+
:where(.hraness-site-shell, body.plain-site, body.hraness-marketing-page) {
|
|
3
|
+
display: flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
min-block-size: 100vh;
|
|
6
|
+
min-block-size: 100svh;
|
|
7
|
+
min-block-size: 100dvh;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
:where(.hraness-site-shell, body.plain-site, body.hraness-marketing-page) > * {
|
|
11
|
+
flex-shrink: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
:where(.hraness-site-shell, body.plain-site, body.hraness-marketing-page) > :where(main, .hraness-site-shell__content) {
|
|
15
|
+
flex: 1 0 auto;
|
|
16
|
+
}
|