@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,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The status-page glyph drawn as a field of dots. On load the dots gather into
|
|
3
|
+
* the glyph; the pointer pushes them aside and a click scatters them, and they
|
|
4
|
+
* light up in the accent color while they move. The simulation stops as soon
|
|
5
|
+
* as every dot is at rest, so an idle page requests no frames. Reduced motion
|
|
6
|
+
* draws the settled glyph once. Without WebGL, or in forced colors, the text
|
|
7
|
+
* glyph underneath stays visible.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const MAX_DOTS = 5200;
|
|
11
|
+
const STRIDE = 4; // x, y, heat, tone
|
|
12
|
+
const SPRING = 0.034;
|
|
13
|
+
const DAMPING = 0.86;
|
|
14
|
+
const PUSH = 5.5;
|
|
15
|
+
const BURST = 24;
|
|
16
|
+
const REST_DISTANCE = 0.08;
|
|
17
|
+
const ACCENT_SHARE = 0.14;
|
|
18
|
+
|
|
19
|
+
const VERTEX = `
|
|
20
|
+
attribute vec2 a_position;
|
|
21
|
+
attribute float a_heat;
|
|
22
|
+
attribute float a_tone;
|
|
23
|
+
uniform vec2 u_size;
|
|
24
|
+
uniform float u_point;
|
|
25
|
+
varying float v_mix;
|
|
26
|
+
void main() {
|
|
27
|
+
vec2 clip = a_position / u_size * 2.0 - 1.0;
|
|
28
|
+
gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
|
|
29
|
+
gl_PointSize = u_point * (1.0 + a_heat * 0.55);
|
|
30
|
+
v_mix = clamp(max(a_tone * 0.85, a_heat), 0.0, 1.0);
|
|
31
|
+
}`;
|
|
32
|
+
|
|
33
|
+
const FRAGMENT = `
|
|
34
|
+
precision mediump float;
|
|
35
|
+
uniform vec3 u_ink;
|
|
36
|
+
uniform vec3 u_accent;
|
|
37
|
+
uniform float u_alpha;
|
|
38
|
+
varying float v_mix;
|
|
39
|
+
void main() {
|
|
40
|
+
float d = length(gl_PointCoord - 0.5);
|
|
41
|
+
float a = smoothstep(0.5, 0.18, d) * u_alpha;
|
|
42
|
+
gl_FragColor = vec4(mix(u_ink, u_accent, v_mix) * a, a);
|
|
43
|
+
}`;
|
|
44
|
+
|
|
45
|
+
type Rgba = readonly [number, number, number, number];
|
|
46
|
+
|
|
47
|
+
export type StatusFieldOptions = Readonly<{
|
|
48
|
+
/** Called once the canvas has painted, and again with false if it stops. */
|
|
49
|
+
onLive?: (live: boolean) => void;
|
|
50
|
+
/** Deterministic seed for the dot layout and intro scatter. */
|
|
51
|
+
seed?: number;
|
|
52
|
+
}>;
|
|
53
|
+
|
|
54
|
+
function random(seed: number): () => number {
|
|
55
|
+
let state = seed >>> 0 || 0x9e3779b9;
|
|
56
|
+
return () => {
|
|
57
|
+
state ^= state << 13; state >>>= 0;
|
|
58
|
+
state ^= state >>> 17;
|
|
59
|
+
state ^= state << 5; state >>>= 0;
|
|
60
|
+
return state / 0x1_0000_0000;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
|
|
65
|
+
const shader = gl.createShader(type);
|
|
66
|
+
if (!shader) return null;
|
|
67
|
+
gl.shaderSource(shader, source);
|
|
68
|
+
gl.compileShader(shader);
|
|
69
|
+
if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
|
|
70
|
+
gl.deleteShader(shader);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Attach the dot field to a `.hraness-status-page__code` element that holds
|
|
76
|
+
* the text glyph and a canvas. Returns the cleanup function.
|
|
77
|
+
*/
|
|
78
|
+
export function attachStatusField(code: HTMLElement, options: StatusFieldOptions = {}): () => void {
|
|
79
|
+
const document = code.ownerDocument;
|
|
80
|
+
const view = document.defaultView;
|
|
81
|
+
const canvas = code.querySelector<HTMLCanvasElement>("canvas");
|
|
82
|
+
const glyph = code.querySelector<HTMLElement>(".hraness-status-page__glyph");
|
|
83
|
+
if (!view || !canvas || !glyph || typeof view.matchMedia !== "function"
|
|
84
|
+
|| typeof view.requestAnimationFrame !== "function"
|
|
85
|
+
|| typeof view.ResizeObserver !== "function") return () => {};
|
|
86
|
+
if (view.matchMedia("(forced-colors: active)").matches) return () => {};
|
|
87
|
+
const motion = view.matchMedia("(prefers-reduced-motion: reduce)");
|
|
88
|
+
|
|
89
|
+
let gl: WebGLRenderingContext | null = null;
|
|
90
|
+
try {
|
|
91
|
+
gl = canvas.getContext("webgl", { alpha: true, antialias: false, depth: false, premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: false });
|
|
92
|
+
} catch { gl = null; }
|
|
93
|
+
if (!gl || gl.isContextLost()) return () => {};
|
|
94
|
+
const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
|
|
95
|
+
const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
|
|
96
|
+
const program = gl.createProgram();
|
|
97
|
+
if (!vertex || !fragment || !program) return () => {};
|
|
98
|
+
gl.attachShader(program, vertex);
|
|
99
|
+
gl.attachShader(program, fragment);
|
|
100
|
+
gl.linkProgram(program);
|
|
101
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return () => {};
|
|
102
|
+
gl.useProgram(program);
|
|
103
|
+
const buffer = gl.createBuffer();
|
|
104
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
|
105
|
+
const attribute = (name: string, size: number, offset: number) => {
|
|
106
|
+
const location = gl.getAttribLocation(program, name);
|
|
107
|
+
gl.enableVertexAttribArray(location);
|
|
108
|
+
gl.vertexAttribPointer(location, size, gl.FLOAT, false, STRIDE * 4, offset * 4);
|
|
109
|
+
};
|
|
110
|
+
attribute("a_position", 2, 0);
|
|
111
|
+
attribute("a_heat", 1, 2);
|
|
112
|
+
attribute("a_tone", 1, 3);
|
|
113
|
+
const uniform = (name: string) => gl.getUniformLocation(program, name);
|
|
114
|
+
const uSize = uniform("u_size");
|
|
115
|
+
const uPoint = uniform("u_point");
|
|
116
|
+
const uInk = uniform("u_ink");
|
|
117
|
+
const uAccent = uniform("u_accent");
|
|
118
|
+
const uAlpha = uniform("u_alpha");
|
|
119
|
+
gl.enable(gl.BLEND);
|
|
120
|
+
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
121
|
+
|
|
122
|
+
const next = random(options.seed ?? 404);
|
|
123
|
+
let homes = new Float32Array(0);
|
|
124
|
+
let velocity = new Float32Array(0);
|
|
125
|
+
let vertices = new Float32Array(0);
|
|
126
|
+
let count = 0;
|
|
127
|
+
let width = 0;
|
|
128
|
+
let height = 0;
|
|
129
|
+
let pointSize = 2;
|
|
130
|
+
let pointer: { x: number; y: number } | undefined;
|
|
131
|
+
let frame: number | undefined;
|
|
132
|
+
let previous: number | undefined;
|
|
133
|
+
let introStart: number | undefined;
|
|
134
|
+
let delays = new Float32Array(0);
|
|
135
|
+
let live = false;
|
|
136
|
+
let disposed = false;
|
|
137
|
+
let lost = false;
|
|
138
|
+
|
|
139
|
+
const probe = document.createElement("canvas");
|
|
140
|
+
probe.width = 1; probe.height = 1;
|
|
141
|
+
const probeContext = probe.getContext("2d", { willReadFrequently: true });
|
|
142
|
+
const rgba = (value: string): Rgba => {
|
|
143
|
+
if (!probeContext) return [0.5, 0.5, 0.5, 1];
|
|
144
|
+
probeContext.clearRect(0, 0, 1, 1);
|
|
145
|
+
probeContext.fillStyle = "#808080";
|
|
146
|
+
probeContext.fillStyle = value;
|
|
147
|
+
probeContext.fillRect(0, 0, 1, 1);
|
|
148
|
+
// ImageData is unpremultiplied, so the channels are the color itself.
|
|
149
|
+
const [r = 128, g = 128, b = 128, a = 255] = probeContext.getImageData(0, 0, 1, 1).data;
|
|
150
|
+
return [r / 255, g / 255, b / 255, a / 255];
|
|
151
|
+
};
|
|
152
|
+
const readColors = () => {
|
|
153
|
+
const ink = rgba(view.getComputedStyle(glyph).color);
|
|
154
|
+
const accent = rgba(view.getComputedStyle(canvas).color);
|
|
155
|
+
gl.uniform3f(uInk, ink[0], ink[1], ink[2]);
|
|
156
|
+
gl.uniform3f(uAccent, accent[0], accent[1], accent[2]);
|
|
157
|
+
gl.uniform1f(uAlpha, Math.max(0.35, ink[3]));
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const sample = (): boolean => {
|
|
161
|
+
// The canvas overhangs the glyph box so scattered dots are not clipped.
|
|
162
|
+
const bounds = canvas.getBoundingClientRect();
|
|
163
|
+
const box = code.getBoundingClientRect();
|
|
164
|
+
const centerX = box.left - bounds.left + box.width / 2;
|
|
165
|
+
const centerY = box.top - bounds.top + box.height / 2;
|
|
166
|
+
width = Math.round(bounds.width);
|
|
167
|
+
height = Math.round(bounds.height);
|
|
168
|
+
if (width < 8 || height < 8) return false;
|
|
169
|
+
const ratio = Math.min(2, view.devicePixelRatio || 1);
|
|
170
|
+
canvas.width = Math.round(width * ratio);
|
|
171
|
+
canvas.height = Math.round(height * ratio);
|
|
172
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
173
|
+
gl.uniform2f(uSize, width, height);
|
|
174
|
+
|
|
175
|
+
const style = view.getComputedStyle(glyph);
|
|
176
|
+
const fontSize = Number.parseFloat(style.fontSize) || 96;
|
|
177
|
+
const mask = document.createElement("canvas");
|
|
178
|
+
mask.width = width; mask.height = height;
|
|
179
|
+
const context = mask.getContext("2d", { willReadFrequently: true });
|
|
180
|
+
if (!context) return false;
|
|
181
|
+
context.font = `${style.fontStyle} ${style.fontWeight} ${fontSize}px ${style.fontFamily}`;
|
|
182
|
+
context.textAlign = "center";
|
|
183
|
+
context.textBaseline = "alphabetic";
|
|
184
|
+
const text = glyph.textContent ?? "";
|
|
185
|
+
const metrics = context.measureText(text);
|
|
186
|
+
const ascent = metrics.actualBoundingBoxAscent || fontSize * 0.7;
|
|
187
|
+
const descent = metrics.actualBoundingBoxDescent || 0;
|
|
188
|
+
context.fillStyle = "#000";
|
|
189
|
+
context.fillText(text, centerX, centerY + (ascent - descent) / 2);
|
|
190
|
+
const pixels = context.getImageData(0, 0, width, height).data;
|
|
191
|
+
|
|
192
|
+
let gap = Math.max(3, Math.min(8, fontSize / 30));
|
|
193
|
+
let points: number[] = [];
|
|
194
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
195
|
+
points = [];
|
|
196
|
+
const rowHeight = gap * 0.866;
|
|
197
|
+
for (let row = 0, y = rowHeight / 2; y < height; row++, y += rowHeight) {
|
|
198
|
+
for (let x = (row % 2 ? gap : gap / 2); x < width; x += gap) {
|
|
199
|
+
if ((pixels[(Math.floor(y) * width + Math.floor(x)) * 4 + 3] ?? 0) >= 128) points.push(x, y);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (points.length / 2 <= MAX_DOTS) break;
|
|
203
|
+
gap *= 1.2;
|
|
204
|
+
}
|
|
205
|
+
const nextCount = points.length / 2;
|
|
206
|
+
const nextHomes = Float32Array.from(points);
|
|
207
|
+
const nextVertices = new Float32Array(nextCount * STRIDE);
|
|
208
|
+
const nextVelocity = new Float32Array(nextCount * 2);
|
|
209
|
+
const nextDelays = new Float32Array(nextCount);
|
|
210
|
+
const intro = count === 0 && !motion.matches;
|
|
211
|
+
for (let index = 0; index < nextCount; index++) {
|
|
212
|
+
const base = index * STRIDE;
|
|
213
|
+
const hx = (nextHomes[index * 2] ?? 0);
|
|
214
|
+
const hy = (nextHomes[index * 2 + 1] ?? 0);
|
|
215
|
+
if (index < count) {
|
|
216
|
+
nextVertices[base] = (vertices[index * STRIDE] ?? 0);
|
|
217
|
+
nextVertices[base + 1] = (vertices[index * STRIDE + 1] ?? 0);
|
|
218
|
+
nextVertices[base + 3] = (vertices[index * STRIDE + 3] ?? 0);
|
|
219
|
+
} else {
|
|
220
|
+
const angle = next() * Math.PI * 2;
|
|
221
|
+
const reach = intro ? (0.25 + next() * 0.6) * Math.max(width, height) * 0.5 : 0;
|
|
222
|
+
nextVertices[base] = hx + Math.cos(angle) * reach;
|
|
223
|
+
nextVertices[base + 1] = hy + Math.sin(angle) * reach * 0.6;
|
|
224
|
+
nextVertices[base + 3] = next() < ACCENT_SHARE ? 1 : 0;
|
|
225
|
+
nextDelays[index] = intro ? next() * 420 : 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
homes = nextHomes; vertices = nextVertices; velocity = nextVelocity; delays = nextDelays; count = nextCount;
|
|
229
|
+
pointSize = gap * 0.6 * ratio;
|
|
230
|
+
gl.uniform1f(uPoint, pointSize);
|
|
231
|
+
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);
|
|
232
|
+
if (intro) introStart = undefined;
|
|
233
|
+
return count > 0;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const draw = () => {
|
|
237
|
+
if (disposed || lost) return;
|
|
238
|
+
gl.clearColor(0, 0, 0, 0);
|
|
239
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
240
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, vertices);
|
|
241
|
+
gl.drawArrays(gl.POINTS, 0, count);
|
|
242
|
+
if (!live) { live = true; options.onLive?.(true); }
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const settle = () => {
|
|
246
|
+
for (let index = 0; index < count; index++) {
|
|
247
|
+
vertices[index * STRIDE] = (homes[index * 2] ?? 0);
|
|
248
|
+
vertices[index * STRIDE + 1] = (homes[index * 2 + 1] ?? 0);
|
|
249
|
+
vertices[index * STRIDE + 2] = 0;
|
|
250
|
+
velocity[index * 2] = 0;
|
|
251
|
+
velocity[index * 2 + 1] = 0;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const step = (time: number) => {
|
|
256
|
+
frame = undefined;
|
|
257
|
+
if (disposed || lost) return;
|
|
258
|
+
if (document.hidden) { previous = undefined; return; }
|
|
259
|
+
introStart ??= time;
|
|
260
|
+
const dt = previous === undefined ? 1 : Math.max(0.25, Math.min(2, (time - previous) / 16.67));
|
|
261
|
+
previous = time;
|
|
262
|
+
const since = time - introStart;
|
|
263
|
+
const radius = Math.max(72, width * 0.16);
|
|
264
|
+
const damping = DAMPING ** dt;
|
|
265
|
+
let moving = false;
|
|
266
|
+
for (let index = 0; index < count; index++) {
|
|
267
|
+
const base = index * STRIDE;
|
|
268
|
+
if (since < (delays[index] ?? 0)) { moving = true; continue; }
|
|
269
|
+
let x = (vertices[base] ?? 0);
|
|
270
|
+
let y = (vertices[base + 1] ?? 0);
|
|
271
|
+
let vx = (velocity[index * 2] ?? 0);
|
|
272
|
+
let vy = (velocity[index * 2 + 1] ?? 0);
|
|
273
|
+
let ax = ((homes[index * 2] ?? 0) - x) * SPRING;
|
|
274
|
+
let ay = ((homes[index * 2 + 1] ?? 0) - y) * SPRING;
|
|
275
|
+
if (pointer) {
|
|
276
|
+
const dx = x - pointer.x;
|
|
277
|
+
const dy = y - pointer.y;
|
|
278
|
+
const distance = Math.hypot(dx, dy);
|
|
279
|
+
if (distance < radius) {
|
|
280
|
+
const force = (1 - distance / radius) ** 2 * PUSH / Math.max(distance, 0.5);
|
|
281
|
+
ax += dx * force;
|
|
282
|
+
ay += dy * force;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
vx = (vx + ax * dt) * damping;
|
|
286
|
+
vy = (vy + ay * dt) * damping;
|
|
287
|
+
x += vx * dt;
|
|
288
|
+
y += vy * dt;
|
|
289
|
+
vertices[base] = x;
|
|
290
|
+
vertices[base + 1] = y;
|
|
291
|
+
velocity[index * 2] = vx;
|
|
292
|
+
velocity[index * 2 + 1] = vy;
|
|
293
|
+
const speed = Math.hypot(vx, vy);
|
|
294
|
+
vertices[base + 2] = Math.max((vertices[base + 2] ?? 0) * 0.93 ** dt, Math.min(1, speed / 2));
|
|
295
|
+
if (speed > REST_DISTANCE * 0.5 || (vertices[base + 2] ?? 0) > 0.02
|
|
296
|
+
|| (!pointer && Math.abs((homes[index * 2] ?? 0) - x) + Math.abs((homes[index * 2 + 1] ?? 0) - y) > REST_DISTANCE)) moving = true;
|
|
297
|
+
}
|
|
298
|
+
if (!moving && !pointer) settle();
|
|
299
|
+
draw();
|
|
300
|
+
if (moving) frame = view.requestAnimationFrame(step);
|
|
301
|
+
else previous = undefined;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const wake = () => {
|
|
305
|
+
if (disposed || lost || motion.matches || frame !== undefined) return;
|
|
306
|
+
frame = view.requestAnimationFrame(step);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const paintStill = () => {
|
|
310
|
+
if (disposed || lost) return;
|
|
311
|
+
if (frame !== undefined) view.cancelAnimationFrame(frame);
|
|
312
|
+
frame = undefined;
|
|
313
|
+
settle();
|
|
314
|
+
draw();
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const rebuild = () => {
|
|
318
|
+
if (disposed || lost) return;
|
|
319
|
+
if (!sample()) return;
|
|
320
|
+
readColors();
|
|
321
|
+
if (motion.matches) paintStill();
|
|
322
|
+
else { draw(); wake(); }
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const local = (event: PointerEvent) => {
|
|
326
|
+
const bounds = canvas.getBoundingClientRect();
|
|
327
|
+
return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
|
328
|
+
};
|
|
329
|
+
const onMove = (event: PointerEvent) => {
|
|
330
|
+
if (motion.matches) return;
|
|
331
|
+
pointer = local(event);
|
|
332
|
+
wake();
|
|
333
|
+
};
|
|
334
|
+
const onLeave = () => { pointer = undefined; wake(); };
|
|
335
|
+
const onDown = (event: PointerEvent) => {
|
|
336
|
+
if (motion.matches) return;
|
|
337
|
+
const origin = local(event);
|
|
338
|
+
const reach = Math.max(120, width * 0.34);
|
|
339
|
+
for (let index = 0; index < count; index++) {
|
|
340
|
+
const dx = (vertices[index * STRIDE] ?? 0) - origin.x;
|
|
341
|
+
const dy = (vertices[index * STRIDE + 1] ?? 0) - origin.y;
|
|
342
|
+
const distance = Math.hypot(dx, dy);
|
|
343
|
+
if (distance >= reach) continue;
|
|
344
|
+
const force = (1 - distance / reach) * BURST / Math.max(distance, 0.5);
|
|
345
|
+
velocity[index * 2] = (velocity[index * 2] ?? 0) + dx * force;
|
|
346
|
+
velocity[index * 2 + 1] = (velocity[index * 2 + 1] ?? 0) + dy * force;
|
|
347
|
+
}
|
|
348
|
+
if (event.pointerType !== "mouse") pointer = undefined;
|
|
349
|
+
wake();
|
|
350
|
+
};
|
|
351
|
+
// The pointer area is the whole status page, so the dots react before the
|
|
352
|
+
// cursor reaches the glyph. The listeners never block scrolling or clicks.
|
|
353
|
+
const area = code.closest<HTMLElement>(".hraness-status-page") ?? code;
|
|
354
|
+
area.addEventListener("pointermove", onMove, { passive: true });
|
|
355
|
+
area.addEventListener("pointerleave", onLeave, { passive: true });
|
|
356
|
+
area.addEventListener("pointerdown", onDown, { passive: true });
|
|
357
|
+
|
|
358
|
+
const onVisibility = () => { if (!document.hidden) wake(); };
|
|
359
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
360
|
+
const onMotion = () => { if (motion.matches) paintStill(); else wake(); };
|
|
361
|
+
motion.addEventListener?.("change", onMotion);
|
|
362
|
+
const scheme = view.matchMedia("(prefers-color-scheme: dark)");
|
|
363
|
+
const onScheme = () => {
|
|
364
|
+
if (disposed || lost) return;
|
|
365
|
+
readColors();
|
|
366
|
+
if (frame === undefined) draw();
|
|
367
|
+
};
|
|
368
|
+
scheme.addEventListener?.("change", onScheme);
|
|
369
|
+
// Theme menus switch palettes through root attributes; repaint with the new ink.
|
|
370
|
+
const themeObserver = typeof view.MutationObserver === "function"
|
|
371
|
+
? new view.MutationObserver(onScheme) : undefined;
|
|
372
|
+
themeObserver?.observe(document.documentElement, { attributeFilter: ["class", "data-theme", "data-palette", "style"], attributes: true });
|
|
373
|
+
let resizeFrame: number | undefined;
|
|
374
|
+
const resizeObserver = new view.ResizeObserver(() => {
|
|
375
|
+
if (resizeFrame !== undefined) view.cancelAnimationFrame(resizeFrame);
|
|
376
|
+
resizeFrame = view.requestAnimationFrame(() => { resizeFrame = undefined; rebuild(); });
|
|
377
|
+
});
|
|
378
|
+
// A lost context is not restored: the text glyph takes over for good.
|
|
379
|
+
const onLost = () => {
|
|
380
|
+
lost = true;
|
|
381
|
+
if (frame !== undefined) view.cancelAnimationFrame(frame);
|
|
382
|
+
frame = undefined;
|
|
383
|
+
if (live) { live = false; options.onLive?.(false); }
|
|
384
|
+
};
|
|
385
|
+
canvas.addEventListener("webglcontextlost", onLost);
|
|
386
|
+
|
|
387
|
+
// Draw with the site's display face once it has loaded, and again if a
|
|
388
|
+
// late font swap changes the glyph.
|
|
389
|
+
const fonts = document.fonts;
|
|
390
|
+
const onFonts = () => rebuild();
|
|
391
|
+
let started = false;
|
|
392
|
+
const start = () => {
|
|
393
|
+
if (started || disposed) return;
|
|
394
|
+
started = true;
|
|
395
|
+
resizeObserver.observe(code);
|
|
396
|
+
rebuild();
|
|
397
|
+
};
|
|
398
|
+
if (fonts?.ready) {
|
|
399
|
+
void fonts.ready.then(start, start);
|
|
400
|
+
fonts.addEventListener?.("loadingdone", onFonts);
|
|
401
|
+
} else start();
|
|
402
|
+
|
|
403
|
+
return () => {
|
|
404
|
+
if (disposed) return;
|
|
405
|
+
disposed = true;
|
|
406
|
+
if (frame !== undefined) view.cancelAnimationFrame(frame);
|
|
407
|
+
if (resizeFrame !== undefined) view.cancelAnimationFrame(resizeFrame);
|
|
408
|
+
area.removeEventListener("pointermove", onMove);
|
|
409
|
+
area.removeEventListener("pointerleave", onLeave);
|
|
410
|
+
area.removeEventListener("pointerdown", onDown);
|
|
411
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
412
|
+
motion.removeEventListener?.("change", onMotion);
|
|
413
|
+
scheme.removeEventListener?.("change", onScheme);
|
|
414
|
+
fonts?.removeEventListener?.("loadingdone", onFonts);
|
|
415
|
+
themeObserver?.disconnect();
|
|
416
|
+
resizeObserver.disconnect();
|
|
417
|
+
canvas.removeEventListener("webglcontextlost", onLost);
|
|
418
|
+
gl.deleteBuffer(buffer);
|
|
419
|
+
gl.deleteProgram(program);
|
|
420
|
+
gl.deleteShader(vertex);
|
|
421
|
+
gl.deleteShader(fragment);
|
|
422
|
+
if (live) options.onLive?.(false);
|
|
423
|
+
// Free the context once the canvas has left the document, so repeated
|
|
424
|
+
// client-side 404s do not pile up contexts. A canvas that is still
|
|
425
|
+
// attached (React StrictMode re-runs effects on it) keeps its context.
|
|
426
|
+
const release = () => {
|
|
427
|
+
if (!canvas.isConnected && !gl.isContextLost()) gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
428
|
+
};
|
|
429
|
+
release();
|
|
430
|
+
view.setTimeout(release, 0);
|
|
431
|
+
};
|
|
432
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parseStatusPageRoutes, suggestStatusRoute } from "../status-page.js";
|
|
2
|
+
import { attachFoil } from "./foil.js";
|
|
3
|
+
import { attachStatusField } from "./status-field.js";
|
|
4
|
+
|
|
5
|
+
export { attachStatusField, type StatusFieldOptions } from "./status-field.js";
|
|
6
|
+
|
|
7
|
+
type NavigationHistory = {
|
|
8
|
+
readonly currentEntry?: { readonly index: number } | null;
|
|
9
|
+
entries?: () => readonly { readonly url: string | null }[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function parseUrl(value: string | null | undefined): URL | undefined {
|
|
13
|
+
if (!value) return undefined;
|
|
14
|
+
try { return new URL(value); } catch { return undefined; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The page the reader was on just before this one. The Navigation API sees
|
|
19
|
+
* client-side route changes and lists only same-origin entries; without it,
|
|
20
|
+
* the referrer covers full page loads.
|
|
21
|
+
*/
|
|
22
|
+
function previousPage(view: Window, document: Document): URL | undefined {
|
|
23
|
+
const navigation = (view as Window & { navigation?: NavigationHistory }).navigation;
|
|
24
|
+
const index = navigation?.currentEntry?.index;
|
|
25
|
+
if (navigation?.entries !== undefined && typeof index === "number") {
|
|
26
|
+
return index < 1 ? undefined : parseUrl(navigation.entries()[index - 1]?.url);
|
|
27
|
+
}
|
|
28
|
+
return view.history.length > 1 ? parseUrl(document.referrer) : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Enhance one `.hraness-status-page` root: draw the glyph as a dot field,
|
|
33
|
+
* offer the closest known page for a missing address, show Back only when the
|
|
34
|
+
* reader arrived from another page on this site, and ease the primary action's
|
|
35
|
+
* foil light. Everything works without it. Returns the cleanup function.
|
|
36
|
+
*/
|
|
37
|
+
export function attachStatusPage(root: HTMLElement): () => void {
|
|
38
|
+
const document = root.ownerDocument;
|
|
39
|
+
const view = document.defaultView;
|
|
40
|
+
if (!view) return () => {};
|
|
41
|
+
const cleanups: (() => void)[] = [];
|
|
42
|
+
|
|
43
|
+
const hint = root.querySelector<HTMLElement>(".hraness-status-page__hint");
|
|
44
|
+
const hintLink = hint?.querySelector<HTMLAnchorElement>("a");
|
|
45
|
+
if (hint && hintLink && root.dataset.kind === "not-found") {
|
|
46
|
+
const match = suggestStatusRoute(
|
|
47
|
+
view.location.pathname,
|
|
48
|
+
parseStatusPageRoutes(root.getAttribute("data-hraness-status-routes")),
|
|
49
|
+
);
|
|
50
|
+
// The route data came from the markup; accept only a page on this origin.
|
|
51
|
+
const target = match === undefined ? undefined : new URL(match.href, view.location.href);
|
|
52
|
+
if (match && target && target.origin === view.location.origin) {
|
|
53
|
+
hintLink.href = target.pathname + target.search + target.hash;
|
|
54
|
+
hintLink.textContent = match.label;
|
|
55
|
+
hint.hidden = false;
|
|
56
|
+
root.dataset.hranessStatusSuggestion = match.href;
|
|
57
|
+
cleanups.push(() => {
|
|
58
|
+
hint.hidden = true;
|
|
59
|
+
hintLink.setAttribute("href", "/");
|
|
60
|
+
hintLink.textContent = "";
|
|
61
|
+
delete root.dataset.hranessStatusSuggestion;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const back = root.querySelector<HTMLAnchorElement>(".hraness-status-page__back");
|
|
67
|
+
if (back) {
|
|
68
|
+
const previous = previousPage(view, document);
|
|
69
|
+
if (previous && previous.origin === view.location.origin && previous.pathname !== view.location.pathname) {
|
|
70
|
+
back.href = previous.href;
|
|
71
|
+
back.hidden = false;
|
|
72
|
+
const onBack = (event: MouseEvent) => {
|
|
73
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
view.history.back();
|
|
76
|
+
};
|
|
77
|
+
back.addEventListener("click", onBack);
|
|
78
|
+
cleanups.push(() => {
|
|
79
|
+
back.removeEventListener("click", onBack);
|
|
80
|
+
back.hidden = true;
|
|
81
|
+
back.setAttribute("href", "/");
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const code = root.querySelector<HTMLElement>(".hraness-status-page__code");
|
|
87
|
+
if (code) {
|
|
88
|
+
cleanups.push(attachStatusField(code, {
|
|
89
|
+
onLive: (live) => {
|
|
90
|
+
if (live) root.dataset.hranessStatusField = "live";
|
|
91
|
+
else delete root.dataset.hranessStatusField;
|
|
92
|
+
},
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
cleanups.push(attachFoil(root));
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
for (const cleanup of cleanups.splice(0).reverse()) cleanup();
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const stickyOffsetCustomProperty = "--hraness-sticky-offset";
|
|
2
|
+
|
|
3
|
+
export const stickyOffsetHeaderSelector = [
|
|
4
|
+
".hraness-marketing-header:not([data-position='static'])",
|
|
5
|
+
".hraness-marketing-header-surface:not([data-position='static'])",
|
|
6
|
+
].join(", ");
|
|
7
|
+
|
|
8
|
+
export interface StickyOffsetSyncOptions {
|
|
9
|
+
readonly header?: Element | string;
|
|
10
|
+
readonly root?: ParentNode;
|
|
11
|
+
readonly target?: HTMLElement;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canQuery(root: ParentNode): boolean {
|
|
15
|
+
return typeof (root as { querySelector?: unknown }).querySelector === "function";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ownerDocumentOf(node: ParentNode | undefined): Document | undefined {
|
|
19
|
+
if (node === undefined) return globalThis.document;
|
|
20
|
+
if (node.nodeType === 9) return node as Document;
|
|
21
|
+
const owner = (node as { ownerDocument?: Document | null }).ownerDocument;
|
|
22
|
+
return owner ?? undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveHeader(
|
|
26
|
+
root: ParentNode,
|
|
27
|
+
header: Element | string | undefined,
|
|
28
|
+
): Element | null {
|
|
29
|
+
if (typeof header === "object") return header;
|
|
30
|
+
if (!canQuery(root)) return null;
|
|
31
|
+
if (typeof header === "string") return root.querySelector(header);
|
|
32
|
+
return root.querySelector(stickyOffsetHeaderSelector);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveTarget(header: Element, target: HTMLElement | undefined): HTMLElement {
|
|
36
|
+
if (target !== undefined) return target;
|
|
37
|
+
const page = header.closest<HTMLElement>(".hraness-marketing-page");
|
|
38
|
+
return page ?? header.ownerDocument.documentElement;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Border-box height of sticky marketing chrome, as a CSS length. */
|
|
42
|
+
export function measureStickyOffset(header: Element): string {
|
|
43
|
+
return `${header.getBoundingClientRect().height}px`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Write --hraness-sticky-offset onto the page or document so siblings inherit it. */
|
|
47
|
+
export function publishStickyOffset(header: Element, target?: HTMLElement): string {
|
|
48
|
+
const value = measureStickyOffset(header);
|
|
49
|
+
resolveTarget(header, target).style.setProperty(stickyOffsetCustomProperty, value);
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Keep --hraness-sticky-offset equal to the live header border box. Safe when
|
|
55
|
+
* ResizeObserver is missing: publishes once. Does not run on import.
|
|
56
|
+
*/
|
|
57
|
+
export function syncStickyOffset(options: StickyOffsetSyncOptions = {}): () => void {
|
|
58
|
+
const document = typeof options.header === "object"
|
|
59
|
+
? options.header.ownerDocument
|
|
60
|
+
: ownerDocumentOf(options.root);
|
|
61
|
+
if (document === undefined) return () => {};
|
|
62
|
+
const root = options.root ?? document;
|
|
63
|
+
const header = resolveHeader(root, options.header);
|
|
64
|
+
if (header === null) return () => {};
|
|
65
|
+
const target = resolveTarget(header, options.target);
|
|
66
|
+
const publish = () => {
|
|
67
|
+
publishStickyOffset(header, target);
|
|
68
|
+
};
|
|
69
|
+
publish();
|
|
70
|
+
const view = document.defaultView;
|
|
71
|
+
if (view === null || typeof view.ResizeObserver !== "function") {
|
|
72
|
+
return () => {
|
|
73
|
+
target.style.removeProperty(stickyOffsetCustomProperty);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const observer = new view.ResizeObserver(publish);
|
|
77
|
+
observer.observe(header);
|
|
78
|
+
return () => {
|
|
79
|
+
observer.disconnect();
|
|
80
|
+
target.style.removeProperty(stickyOffsetCustomProperty);
|
|
81
|
+
};
|
|
82
|
+
}
|