@master/internal-site 0.2.4 → 0.2.5

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.
@@ -0,0 +1,182 @@
1
+ <script lang="ts" module>
2
+ import type { Attachment } from 'svelte/attachments'
3
+ import type { DocumentationSearchPage } from '../utils/documentation-search'
4
+
5
+ const cache: Record<string, Promise<DocumentationSearchPage[]>> = {}
6
+
7
+ async function loadPages(locale: string) {
8
+ cache[locale] ??= fetch(`/search/${encodeURIComponent(locale)}.json`).then(response => {
9
+ if (!response.ok) throw new Error(`Search unavailable (${response.status})`)
10
+ return response.json()
11
+ }).catch(error => { delete cache[locale]; throw error })
12
+ return cache[locale]
13
+ }
14
+
15
+ /** The dialog lives at the end of the body, outside the island that opens it. */
16
+ const portal: Attachment<HTMLElement> = (node) => {
17
+ document.body.append(node)
18
+ return () => node.remove()
19
+ }
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ import '@master/internal-site/styles/interface.css'
24
+ import '@master/internal-site/styles/system/dialog.css'
25
+
26
+ // SearchDialog loads documentation-search.css before mounting the panel.
27
+ import { untrack } from 'svelte'
28
+ import Link from './Link.svelte'
29
+ import { getSite } from '../site'
30
+ import { createDocumentationSearch } from '../utils/documentation-search'
31
+ import { openSearchEvent } from '../contexts/search'
32
+ import IconArrowDown from '@tabler/icons-svelte-runes/icons/arrow-down'
33
+ import IconArrowUp from '@tabler/icons-svelte-runes/icons/arrow-up'
34
+ import IconCornerDownLeft from '@tabler/icons-svelte-runes/icons/corner-down-left'
35
+ import IconSearch from '@tabler/icons-svelte-runes/icons/search'
36
+ import IconX from '@tabler/icons-svelte-runes/icons/x'
37
+ import { isInteractOutside } from '../utils/isInteractOutside'
38
+
39
+ interface Props {
40
+ initialTrigger?: HTMLElement | null
41
+ }
42
+
43
+ let { initialTrigger }: Props = $props()
44
+ const { t, locale } = getSite()
45
+ let opened = $state(false)
46
+ let query = $state('')
47
+ let shortcutModifier = $state('⌘')
48
+ let pages = $state.raw<DocumentationSearchPage[]>([])
49
+ let error = $state('')
50
+ let loading = $state(false)
51
+ let retry = $state(0)
52
+ let selected = $state(0)
53
+ let dialog = $state<HTMLDialogElement>()
54
+ let input = $state<HTMLInputElement>()
55
+ let scroll = $state<HTMLDivElement>()
56
+ let scrollTop = 0
57
+ let returnFocus: HTMLElement | null = null
58
+ const search = $derived(createDocumentationSearch(pages))
59
+ const results = $derived(search(query))
60
+
61
+ function selectQuery(value: string) {
62
+ query = value
63
+ selected = 0
64
+ scrollTop = 0
65
+ if (scroll) scroll.scrollTop = 0
66
+ input?.focus()
67
+ }
68
+
69
+ function close() {
70
+ scrollTop = scroll?.scrollTop ?? 0
71
+ dialog?.close()
72
+ opened = false
73
+ returnFocus?.focus()
74
+ }
75
+
76
+ function open(trigger?: HTMLElement | null) {
77
+ shortcutModifier = /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl'
78
+ returnFocus = trigger ?? document.activeElement as HTMLElement | null
79
+ opened = true
80
+ }
81
+
82
+ $effect(() => {
83
+ if (!opened) return
84
+ void retry
85
+ let active = true
86
+ loading = true
87
+ error = ''
88
+ loadPages(locale).then(data => {
89
+ if (active) { pages = data; loading = false }
90
+ }, reason => {
91
+ if (active) { error = reason.message; loading = false }
92
+ })
93
+ return () => { active = false }
94
+ })
95
+
96
+ $effect(() => {
97
+ if (opened && dialog) {
98
+ untrack(() => {
99
+ if (!dialog!.open) dialog!.showModal()
100
+ input?.focus()
101
+ if (scroll) scroll.scrollTop = scrollTop
102
+ })
103
+ }
104
+ })
105
+
106
+ const closeOnBackdropClick: Attachment<HTMLDialogElement> = (element) => {
107
+ const onclick = (event: MouseEvent) => {
108
+ if (event.target === element && isInteractOutside(element, event)) close()
109
+ }
110
+ element.addEventListener('click', onclick)
111
+ return () => element.removeEventListener('click', onclick)
112
+ }
113
+
114
+ $effect(() => {
115
+ const request = (event: Event) => open((event as CustomEvent<{ trigger?: HTMLElement }>).detail?.trigger)
116
+ window.addEventListener(openSearchEvent, request)
117
+ return () => window.removeEventListener(openSearchEvent, request)
118
+ })
119
+
120
+ untrack(() => open(initialTrigger))
121
+
122
+ function shortcut(event: KeyboardEvent) {
123
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); opened ? close() : open() }
124
+ }
125
+
126
+ function onkeydown(event: KeyboardEvent) {
127
+ if (event.isComposing || event.keyCode === 229) return
128
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return }
129
+ if (event.key === 'Enter' && results.length) { event.preventDefault(); document.getElementById(`documentation-result-${selected}`)?.click() }
130
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
131
+ event.preventDefault()
132
+ const next = Math.max(0, Math.min(results.length - 1, selected + (event.key === 'ArrowDown' ? 1 : -1)))
133
+ selected = next
134
+ document.getElementById(`documentation-result-${next}`)?.scrollIntoView({ block: 'nearest' })
135
+ }
136
+ }
137
+
138
+ const kinds: Record<string, string> = { utility: 'Utility', rule: 'Rule', tokens: 'Tokens', directive: 'Directive', tool: 'Tool', package: 'API' }
139
+
140
+ </script>
141
+
142
+ <svelte:window onkeydown={shortcut} />
143
+
144
+ {#snippet excerpt(text: string, query: string)}
145
+ {@const index = text.toLowerCase().indexOf(query.toLowerCase())}
146
+ {@const start = Math.max(0, index - 45)}
147
+ {@const value = text.slice(start, start + 220)}
148
+ {@const offset = value.toLowerCase().indexOf(query.toLowerCase())}
149
+ {start > 0 ? '…' : ''}{#if offset < 0}{value}{:else}{value.slice(0, offset)}<mark>{value.slice(offset, offset + query.length)}</mark>{value.slice(offset + query.length)}{/if}{start + 220 < text.length ? '…' : ''}
150
+ {/snippet}
151
+
152
+ {#if opened}
153
+ <dialog bind:this={dialog} {@attach portal} {@attach closeOnBackdropClick} class="ui-dialog" data-variant="search" aria-label={t('Search documentation')} oncancel={close}>
154
+ <div class="ui-dialog-search">
155
+ <IconSearch size={18} stroke={1.5} aria-hidden="true" />
156
+ <input bind:this={input} type="search" role="combobox" aria-autocomplete="list" aria-expanded={Boolean(query)} value={query} autocomplete="off" spellcheck={false} placeholder={t('Search documentation…')} aria-label={t('Search documentation')} aria-controls="documentation-search-results" aria-activedescendant={results[selected] ? `documentation-result-${selected}` : undefined}
157
+ oninput={event => selectQuery(event.currentTarget.value)}
158
+ {onkeydown} />
159
+ {#if query}<button type="button" class="ui-button" data-variant="quiet" data-shape="icon" data-size="sm" onclick={() => selectQuery('')} aria-label={t('Clear search')}><IconX size={16} stroke={1.5} aria-hidden="true" /></button>{/if}
160
+ <button type="button" class="ui-button ui-dialog-close" data-variant="quiet" data-size="sm" onclick={close} aria-label={t('Close search')}><span class="ui-kbd">esc</span><span>{t('Cancel')}</span></button>
161
+ </div>
162
+ <div class="ui-dialog-body" bind:this={scroll}>
163
+ <p class="ui-dialog-status" role="status">{loading ? (t('Loading documentation…')) : error ? (t('Search could not load.')) : query ? t(results.length === 40 ? 'Top {count} results' : '{count} results').replace('{count}', String(results.length)) : (t('Find a name, alias or CSS property'))}</p>
164
+ {#if error}<div class="ui-dialog-note"><p>{t('Check your connection and try again.')}</p><button type="button" class="ui-button" data-size="sm" onclick={() => { retry += 1; input?.focus() }}>{t('Try again')}</button></div>{/if}
165
+ {#if !query && !error && !loading}<div class="ui-dialog-note">
166
+ <p>{t('Try searching')}</p>
167
+ {#each ['padding', 'padding-inline-start:', 'fg-red:hover@sm'] as value (value)}<button type="button" class="ui-button" data-size="sm" onclick={() => selectQuery(value)}><code>{value}</code><IconCornerDownLeft size={14} stroke={1.5} aria-hidden="true" /></button>{/each}
168
+ </div>{/if}
169
+ {#if query && !results.length && !error && !loading}<div class="ui-dialog-note" data-align="center"><IconSearch size={24} stroke={1.25} aria-hidden="true" /><b>{t('No matching documents')}</b><p>{t('Try a property name, a token name or a different keyword.')}</p></div>{/if}
170
+ <ol id="documentation-search-results" class="ui-results" role="listbox" aria-label={t('Search results')} aria-busy={loading}>
171
+ {#if !error}{#each results as result, index (result.page.url)}<li role="presentation">
172
+ <Link id={`documentation-result-${index}`} role="option" tabindex={-1} aria-selected={selected === index} href={result.href} onclick={close} onpointermove={() => { selected = index }}>
173
+ <small>{t(result.page.category ?? '')} · {t(kinds[result.page.kind ?? ''] ?? (result.page.url.includes('/guide') ? 'Guide' : 'Page'))} · {t(result.reason)}</small>
174
+ <b>{result.page.title}</b>
175
+ <span>{@render excerpt(result.excerpt, query)}</span>
176
+ </Link>
177
+ </li>{/each}{/if}
178
+ </ol>
179
+ </div>
180
+ <div class="ui-dialog-foot" aria-hidden="true"><span><span class="ui-kbd"><IconArrowUp size={12} /></span><span class="ui-kbd"><IconArrowDown size={12} /></span>{t('Navigate')}</span><span><span class="ui-kbd"><IconCornerDownLeft size={12} /></span>{t('Open')}</span><span class="ui-search-shortcut">{t('Search anytime')}<kbd class="ui-kbd">{shortcutModifier}</kbd><kbd class="ui-kbd">K</kbd></span></div>
181
+ </dialog>
182
+ {/if}
@@ -1,11 +1,19 @@
1
1
  /** The event a search trigger dispatches; `components/SearchDialog.svelte` listens for it. */
2
2
  export const openSearchEvent = 'site-search:open'
3
3
 
4
+ /** Signals likely intent, so the dialog can load its panel and styles before the first opening. */
5
+ export const prefetchSearchEvent = 'site-search:prefetch'
6
+
4
7
  /** Search triggers live in independent islands; they open the page's single dialog by event. */
5
8
  export function openSearch(trigger?: HTMLElement) {
6
9
  window.dispatchEvent(new CustomEvent(openSearchEvent, { detail: { trigger } }))
7
10
  }
8
11
 
12
+ /** Search triggers call this on pointer or focus intent; the dialog loads once. */
13
+ export function prefetchSearch() {
14
+ window.dispatchEvent(new CustomEvent(prefetchSearchEvent))
15
+ }
16
+
9
17
  /** The search trigger's words in the page's language. */
10
18
  export function searchLabels(translate: (text: string) => string, locale: string) {
11
19
  const searchPlaceholder = translate('Search ⌘ K …')
@@ -14,7 +14,12 @@ let themeMode: ThemeMode | undefined
14
14
 
15
15
  /** One page-wide ThemeMode so every island shows and changes the same preference. */
16
16
  function getThemeMode() {
17
- themeMode ??= new ThemeMode({ preference: 'system' }).init()
17
+ if (!themeMode) {
18
+ themeMode = new ThemeMode({ preference: 'system' }).init()
19
+ // ThemeMode only removes a mode it set itself, so a different mode the head script chose would stay beside it.
20
+ const root = document.documentElement
21
+ for (const mode of ['dark', 'light']) if (mode !== themeMode.value) root.classList.remove(mode)
22
+ }
18
23
  return themeMode
19
24
  }
20
25
 
@@ -1,7 +1,9 @@
1
1
  /** Code blocks render as static markup; these delegated handlers switch tabs and copy code. */
2
- import { readSiteContext } from '../site'
2
+ import { loadSiteTranslations } from '../site-dictionary'
3
3
 
4
- const t = (text: string) => readSiteContext().translations[text] ?? text
4
+ // Handlers attach at once; copy reads in the source language until the dictionary arrives.
5
+ let translations: Record<string, string> = {}
6
+ const t = (text: string) => translations[text] ?? text
5
7
 
6
8
  const tabChange = 'site-code-tabs:change'
7
9
  const resetTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>()
@@ -122,6 +124,9 @@ window.addEventListener('storage', event => {
122
124
  })
123
125
 
124
126
  // The bootstrap script restored persisted choices before paint; align their copy labels.
125
- document.querySelectorAll<HTMLElement>('[data-code-tabs][data-code-tabs-storage-key]').forEach(root => {
126
- if (root.dataset.codeTabsCurrentName) selectTab(root, root.dataset.codeTabsCurrentName)
127
+ void loadSiteTranslations().then(loaded => {
128
+ translations = loaded ?? {}
129
+ document.querySelectorAll<HTMLElement>('[data-code-tabs][data-code-tabs-storage-key]').forEach(root => {
130
+ if (root.dataset.codeTabsCurrentName) selectTab(root, root.dataset.codeTabsCurrentName)
131
+ })
127
132
  })
@@ -0,0 +1,36 @@
1
+ import { siteContextElementId } from './site'
2
+
3
+ let loading: Promise<Record<string, string> | undefined> | undefined
4
+ let applying: Promise<void> | undefined
5
+
6
+ /**
7
+ * The page's island dictionary. Development pages serialize it inline; built pages
8
+ * name a cached per-locale file on the context element and preload it in the head.
9
+ * Without a dictionary, copy reads in the source language, as unknown keys do.
10
+ */
11
+ export function loadSiteTranslations(): Promise<Record<string, string> | undefined> {
12
+ return loading ??= (async () => {
13
+ const element = document.getElementById(siteContextElementId)
14
+ const href = element?.dataset.translations
15
+ if (!href) return element?.textContent ? JSON.parse(element.textContent).translations : undefined
16
+ try {
17
+ const response = await fetch(href)
18
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
19
+ return await response.json() as Record<string, string>
20
+ } catch (error) {
21
+ console.error(`Failed to load the site dictionary ${href}`, error)
22
+ return undefined
23
+ }
24
+ })()
25
+ }
26
+
27
+ /** Complete the serialized site context with the loaded dictionary before islands read it. */
28
+ export function applySiteTranslations(): Promise<void> {
29
+ return applying ??= (async () => {
30
+ const element = document.getElementById(siteContextElementId)
31
+ if (!element?.dataset.translations || !element.textContent) return
32
+ const translations = await loadSiteTranslations()
33
+ if (translations) element.textContent = JSON.stringify({ ...JSON.parse(element.textContent), translations })
34
+ delete element.dataset.translations
35
+ })()
36
+ }
@@ -3,4 +3,6 @@ export interface StaticImage {
3
3
  src: string
4
4
  width: number
5
5
  height: number
6
+ /** Width-described candidates the site build encoded for this import. */
7
+ srcset?: string
6
8
  }
@@ -33,6 +33,9 @@
33
33
 
34
34
  let selectedId = $state<string>()
35
35
  const selected = $derived(articles.find(article => article.id === selectedId))
36
+ // The detail view is always rendered, hidden until an article is selected, so the
37
+ // server HTML carries every class it uses and its CSS is pre-rendered with the page.
38
+ const detail = $derived(selected ?? articles[0])
36
39
  let heading = $state<HTMLHeadingElement>()
37
40
  const buttons = new Map<string, HTMLButtonElement>()
38
41
  const transition = useDemoViewTransition()
@@ -51,20 +54,19 @@
51
54
  <DemoLabel>Field notes / Collection 01</DemoLabel>
52
55
  <span class="text-xs fg-text-muted" role="status">{selected ? 'Article detail' : '2 articles'} · {transition.mode}</span>
53
56
  </div>
54
- {#if selected}
55
- <article data-article-detail class="overflow-hidden ui-sheet" data-elevation="1">
56
- <Image alt={selected.imageAlt} src={selected.image} loading="eager" sizes="(max-width: 600px) 100vw, 600px"
57
- style={`view-transition-name:article-${selected.id}-image`} class="block h-auto w-full (aspect-ratio:16/9) object-cover article-capture" />
58
- <div class="p-lg">
59
- <time datetime={selected.id === 'aurora' ? '2026-05-12' : '2026-05-09'} style={`view-transition-name:article-${selected.id}-date`} class="text-xs fg-text-muted article-capture">{selected.date}</time>
60
- <h1 bind:this={heading} tabindex="-1" style={`view-transition-name:article-${selected.id}-title`} class="mb(0) mt-sm text-2xl font-regular outline-solid:focus-visible outline(2):focus-visible outline-text-link:focus-visible outline-offset-4xs:focus-visible article-capture">{selected.title}</h1>
61
- <p class="mb(0) mt-md text-sm fg-text-muted">{selected.description} The image, title and date keep their names in both views. Each snapshot can move to its new bounds while surrounding content fades.</p>
62
- <button type="button" class="mt-lg ui-button" onclick={() => { const id = selected.id; transition.run(() => { selectedId = undefined }, () => buttons.get(id) ?? null) }}>
63
- <IconArrowLeft aria-hidden="true" />Back to collection
64
- </button>
65
- </div>
66
- </article>
67
- {:else}
57
+ <article data-article-detail hidden={!selected} class="overflow-hidden ui-sheet" data-elevation="1">
58
+ <Image alt={detail.imageAlt} src={detail.image} loading="eager" sizes="(max-width: 600px) 100vw, 600px"
59
+ style={`view-transition-name:article-${detail.id}-image`} class="block h-auto w-full (aspect-ratio:16/9) object-cover article-capture" />
60
+ <div class="p-lg">
61
+ <time datetime={detail.id === 'aurora' ? '2026-05-12' : '2026-05-09'} style={`view-transition-name:article-${detail.id}-date`} class="text-xs fg-text-muted article-capture">{detail.date}</time>
62
+ <h1 bind:this={heading} tabindex="-1" style={`view-transition-name:article-${detail.id}-title`} class="mb(0) mt-sm text-2xl font-regular outline-solid:focus-visible outline(2):focus-visible outline-text-link:focus-visible outline-offset-4xs:focus-visible article-capture">{detail.title}</h1>
63
+ <p class="mb(0) mt-md text-sm fg-text-muted">{detail.description} The image, title and date keep their names in both views. Each snapshot can move to its new bounds while surrounding content fades.</p>
64
+ <button type="button" class="mt-lg ui-button" onclick={() => { const id = detail.id; transition.run(() => { selectedId = undefined }, () => buttons.get(id) ?? null) }}>
65
+ <IconArrowLeft aria-hidden="true" />Back to collection
66
+ </button>
67
+ </div>
68
+ </article>
69
+ {#if !selected}
68
70
  <div class="grid-cols(1) grid gap-md grid-cols(2)@container(width>=28rem)">
69
71
  {#each articles as article (article.id)}
70
72
  <DemoSurface elevation="raised" class="flex overflow-hidden flex-col">
@@ -25,7 +25,7 @@ export function mountHomeLamp(root: HTMLElement) {
25
25
  const lit = root.getElementsByClassName('home-lit')
26
26
  const visible = new Set<HTMLElement>()
27
27
  const observed = new WeakSet<Element>()
28
- const values = new WeakMap<HTMLElement, string>()
28
+ const values = new WeakMap<HTMLElement, string[]>()
29
29
  const reduced = matchMedia('(prefers-reduced-motion: reduce)')
30
30
  const finePointer = matchMedia('(hover: hover) and (pointer: fine)')
31
31
  const tilt = { x: 0, y: 0 }
@@ -80,7 +80,7 @@ export function mountHomeLamp(root: HTMLElement) {
80
80
  }
81
81
 
82
82
  function request() {
83
- if (!frame && !disposed) frame = requestAnimationFrame(render)
83
+ if (!frame && !disposed && !document.hidden) frame = requestAnimationFrame(render)
84
84
  }
85
85
 
86
86
  function render(now: number) {
@@ -93,12 +93,23 @@ export function mountHomeLamp(root: HTMLElement) {
93
93
  lamp.y += (target.y - lamp.y) * ease
94
94
  lamp.z += (target.z - lamp.z) * ease
95
95
  lamp.near += (target.near - lamp.near) * ease
96
+ // Measure the whole visible set before changing any styles. Interleaving each measurement
97
+ // with six inherited custom-property writes forces style/layout work for the next stone.
98
+ const updates: [HTMLElement, string[]][] = []
96
99
  for (const element of visible) {
97
100
  if (!element.isConnected) {
98
101
  visible.delete(element)
99
102
  continue
100
103
  }
101
- light(element)
104
+ const rect = element.getBoundingClientRect()
105
+ if (rect.width && rect.height) updates.push([element, lampValues(rect, lamp)])
106
+ }
107
+ for (const [element, next] of updates) {
108
+ const previous = values.get(element)
109
+ next.forEach((value, index) => {
110
+ if (previous?.[index] !== value) element.style.setProperty(litProperties[index], value)
111
+ })
112
+ values.set(element, next)
102
113
  }
103
114
  const moving = Math.abs(target.x - lamp.x) + Math.abs(target.y - lamp.y) + Math.abs(target.z - lamp.z) > 0.5
104
115
  || Math.abs(target.near - lamp.near) > 0.004
@@ -107,14 +118,21 @@ export function mountHomeLamp(root: HTMLElement) {
107
118
  if (moving) request()
108
119
  }
109
120
 
110
- function light(element: HTMLElement) {
111
- const rect = element.getBoundingClientRect()
112
- if (!rect.width || !rect.height) return
113
- const lit = lampValues(rect, lamp)
114
- const key = lit.join('|')
115
- if (values.get(element) === key) return
116
- values.set(element, key)
117
- lit.forEach((value, index) => element.style.setProperty(litProperties[index], value))
121
+ function onScroll() {
122
+ // Reduced motion keeps the resting light instead of repainting shadows throughout a swipe.
123
+ if (!reduced.matches) request()
124
+ }
125
+
126
+ function onVisibility() {
127
+ cancelAnimationFrame(frame)
128
+ frame = 0
129
+ lastFrame = 0
130
+ request()
131
+ }
132
+
133
+ function onMotion() {
134
+ tilt.x = tilt.y = 0
135
+ onLeave()
118
136
  }
119
137
 
120
138
  function onPointer(event: PointerEvent) {
@@ -172,8 +190,10 @@ export function mountHomeLamp(root: HTMLElement) {
172
190
  }
173
191
 
174
192
  observe()
175
- addEventListener('scroll', request, { passive: true })
193
+ addEventListener('scroll', onScroll, { passive: true })
176
194
  addEventListener('resize', onResize, { passive: true })
195
+ document.addEventListener('visibilitychange', onVisibility)
196
+ reduced.addEventListener('change', onMotion)
177
197
  document.addEventListener('pointermove', onPointer, { passive: true })
178
198
  document.documentElement.addEventListener('pointerleave', onLeave)
179
199
  root.addEventListener('focusin', onFocus)
@@ -188,8 +208,10 @@ export function mountHomeLamp(root: HTMLElement) {
188
208
  cancelAnimationFrame(frame)
189
209
  intersection.disconnect()
190
210
  reading.disconnect()
191
- removeEventListener('scroll', request)
211
+ removeEventListener('scroll', onScroll)
192
212
  removeEventListener('resize', onResize)
213
+ document.removeEventListener('visibilitychange', onVisibility)
214
+ reduced.removeEventListener('change', onMotion)
193
215
  document.removeEventListener('pointermove', onPointer)
194
216
  document.documentElement.removeEventListener('pointerleave', onLeave)
195
217
  root.removeEventListener('focusin', onFocus)
@@ -123,6 +123,12 @@
123
123
  "dark": "exec-c7475be3-fd48-4d07-ba69-857c7b748950.png",
124
124
  "generationPrompt": "Use case: stylized-concept. Asset type: premium Master CSS homepage icon, transparent square, intended at 96 CSS pixels. The supplied image is STYLE AND MATERIAL REFERENCE ONLY, not the subject. Create a NEW distinct icon described below. Match its shallow isometric angle, ultra-thin nearly colorless liquid-glass planes, softly rounded corners, fine neutral gray outlines, tiny white edge glints, softly varying transparent faces and very restrained overlap shading. LIGHT UI variant for pure-white/zinc cards. Actual alpha transparency outside and through glass. Precise minimalist functional composition, centered optically, roughly 78–82% of canvas extent, generous margins. Code strokes are neutral gray. No text, letters, numbers, logos, bright colors, blue cast, rainbow, chunky extrusion, metallic bevel, bloom, opaque background or baked checkerboard. No crossing or intersecting geometry except deliberate transparent stacking. Subject: Static rendering: THREE small transparent source fragments staggered above-left. ONE larger finished transparent isometric stylesheet below-right has exactly THREE parallel short code strokes. ONE fine elbow arrow leads from the fragment group to the sheet with a clean empty gap at each end."
125
125
  },
126
+ {
127
+ "id": "render-prerender",
128
+ "light": "exec-81035d42-24de-4bf2-a325-4f01106830da.png",
129
+ "dark": "exec-684afb48-caef-48c2-ad1e-d18e89eb0bf3.png",
130
+ "derivation": "Derived on 2026-11-07 from the render-progressive exports: the largest alpha component (the sheet and its three code strokes) is kept, the floating fragment and elbow arrow are masked out, and the sheet is recentered and scaled to the visible extent of render-static. No pixels were generated or repainted. Replace with a dedicated image_gen illustration when one is commissioned."
131
+ },
126
132
  {
127
133
  "id": "render-progressive",
128
134
  "light": "exec-81035d42-24de-4bf2-a325-4f01106830da.png",
@@ -35,6 +35,8 @@ import artwork16Light from '@master/internal-site/home/icons/artwork/render-stat
35
35
  import artwork16Dark from '@master/internal-site/home/icons/artwork/render-static-dark.webp'
36
36
  import artwork17Light from '@master/internal-site/home/icons/artwork/render-progressive-light.webp'
37
37
  import artwork17Dark from '@master/internal-site/home/icons/artwork/render-progressive-dark.webp'
38
+ import artworkPrerenderLight from '@master/internal-site/home/icons/artwork/render-prerender-light.webp'
39
+ import artworkPrerenderDark from '@master/internal-site/home/icons/artwork/render-prerender-dark.webp'
38
40
  import artwork18Light from '@master/internal-site/home/icons/artwork/render-runtime-light.webp'
39
41
  import artwork18Dark from '@master/internal-site/home/icons/artwork/render-runtime-dark.webp'
40
42
  import artwork19Light from '@master/internal-site/home/icons/artwork/agent-check-preview-apply-light.webp'
@@ -103,6 +105,7 @@ export const homeIconArtwork = {
103
105
  'editor-focused-highlighting': { light: artwork14Light, dark: artwork14Dark },
104
106
  'editor-color-tools': { light: artwork15Light, dark: artwork15Dark },
105
107
  'render-static': { light: artwork16Light, dark: artwork16Dark },
108
+ 'render-prerender': { light: artworkPrerenderLight, dark: artworkPrerenderDark },
106
109
  'render-progressive': { light: artwork17Light, dark: artwork17Dark },
107
110
  'render-runtime': { light: artwork18Light, dark: artwork18Dark },
108
111
  'agent-check-preview-apply': { light: artwork19Light, dark: artwork19Dark },
@@ -19,6 +19,7 @@ import EditorManifestCompletions from '@master/internal-site/home/icons/editor-m
19
19
  import EditorFocusedHighlighting from '@master/internal-site/home/icons/editor-focused-highlighting.svg'
20
20
  import EditorColorTools from '@master/internal-site/home/icons/editor-color-tools.svg'
21
21
  import RenderStatic from '@master/internal-site/home/icons/render-static.svg'
22
+ import RenderPrerender from '@master/internal-site/home/icons/render-prerender.svg'
22
23
  import RenderProgressive from '@master/internal-site/home/icons/render-progressive.svg'
23
24
  import RenderRuntime from '@master/internal-site/home/icons/render-runtime.svg'
24
25
  import AgentCheckPreviewApply from '@master/internal-site/home/icons/agent-check-preview-apply.svg'
@@ -87,7 +88,7 @@ export interface HomeIconDefinition {
87
88
  CompactIcon?: SvgComponent
88
89
  }
89
90
 
90
- /** 41 themed WebP illustrations; 15 dense-list themes also retain archived 32-unit SVG studies. */
91
+ /** 42 themed WebP illustrations; 15 dense-list themes also retain archived 32-unit SVG studies. */
91
92
  export const homeIcons = [
92
93
  {
93
94
  id: 'section-design-system', group: 'sections', label: 'Design system',
@@ -208,6 +209,13 @@ export const homeIcons = [
208
209
  concept: 'Source fragments resolve into a finished stylesheet.',
209
210
  recommendedSize: 96, Icon: RenderStatic
210
211
  },
212
+ {
213
+ id: 'render-prerender', group: 'rendering', label: 'Prerendering',
214
+ artwork: homeIconArtwork['render-prerender'],
215
+ target: { section: 'delivery-modes', item: 'prerender' }, placement: 'card',
216
+ concept: 'A finished page panel already carries its rules.',
217
+ recommendedSize: 96, Icon: RenderPrerender
218
+ },
211
219
  {
212
220
  id: 'render-progressive', group: 'rendering', label: 'Progressive rendering',
213
221
  artwork: homeIconArtwork['render-progressive'],
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
2
+ <g transform="translate(6 -14)">
3
+ <path d="M39.95 46.63 L73.57 63.85 C75.38 64.78 75.38 66.28 73.57 67.21 L50.61 78.97 C48.8 79.9 45.86 79.9 44.05 78.97 L10.43 61.75 C8.62 60.82 8.62 59.32 10.43 58.39 L33.39 46.63 C35.2 45.7 38.14 45.7 39.95 46.63 Z" fill="currentColor" fill-opacity="0.02" stroke="none" />
4
+ <path d="M39.95 44.83 L73.57 62.05 C75.38 62.98 75.38 64.48 73.57 65.41 L50.61 77.17 C48.8 78.1 45.86 78.1 44.05 77.17 L10.43 59.95 C8.62 59.02 8.62 57.52 10.43 56.59 L33.39 44.83 C35.2 43.9 38.14 43.9 39.95 44.83 Z" fill="currentColor" fill-opacity="0.03" stroke="none" />
5
+ <path d="M39.95 44.83 L73.57 62.05 C75.38 62.98 75.38 64.48 73.57 65.41 L50.61 77.17 C48.8 78.1 45.86 78.1 44.05 77.17 L10.43 59.95 C8.62 59.02 8.62 57.52 10.43 56.59 L33.39 44.83 C35.2 43.9 38.14 43.9 39.95 44.83 Z" fill="white" fill-opacity="0.07" stroke="none" />
6
+ <path d="M39.95 44.83 L73.57 62.05 C75.38 62.98 75.38 64.48 73.57 65.41 L50.61 77.17 C48.8 78.1 45.86 78.1 44.05 77.17 L10.43 59.95 C8.62 59.02 8.62 57.52 10.43 56.59 L33.39 44.83 C35.2 43.9 38.14 43.9 39.95 44.83 Z" stroke-opacity="0.42" />
7
+ <path d="M40 45.15 L72.75 61.92 C74.51 62.83 74.51 64.29 72.75 65.2 L50.39 76.65 C48.62 77.55 45.76 77.55 44 76.65 L11.25 59.88 C9.49 58.97 9.49 57.51 11.25 56.6 L33.61 45.15 C35.38 44.25 38.24 44.25 40 45.15 Z" stroke="white" stroke-width="0.6" stroke-opacity="0.5" />
8
+ <path d="M36.26 51.34 L58.4 62.68" stroke-width="1.6" stroke-opacity="0.58" />
9
+ <path d="M29.7 54.7 L46.92 63.52" stroke-width="1.6" stroke-opacity="0.58" />
10
+ <path d="M23.14 58.06 L43.64 68.56" stroke-width="1.6" stroke-opacity="0.58" />
11
+ </g>
12
+ </svg>
@@ -5,6 +5,7 @@ import fontCSS from '../public/fonts/index.css?raw'
5
5
  import { codeTabsBootstrap } from '../docs-shell/scripts/code-tabs-bootstrap'
6
6
  import { resolveHead } from '../docs-shell/utils/head'
7
7
  import { siteContextOf } from '../astro/site-context'
8
+ import { translationAsset } from '../astro/site-translations'
8
9
  import { localizePathname } from '../astro/route-paths'
9
10
  import { siteContextElementId } from '../docs-shell/site'
10
11
  import type { Metadata } from '../docs-shell/types/Metadata'
@@ -32,14 +33,23 @@ const languages = locales.length > 1
32
33
  ? { ...Object.fromEntries(locales.map((locale) => [hreflangOf(locale), localizePathname(basePathname, locale, i18n.defaultLocale)])), 'x-default': basePathname }
33
34
  : undefined
34
35
  const head = resolveHead(metadata, { project: config.name, siteURL: config.origin, pathname: site.pathname, languages })
35
- const siteContextJSON = JSON.stringify(site).replace(/</g, '\\u003c')
36
+ // Built pages name the locale's cached dictionary file instead of repeating it in every page.
37
+ const translations = import.meta.env.PROD ? translationAsset(site.translations) : undefined
38
+ const siteContextJSON = JSON.stringify(translations ? { ...site, translations: {} } : site).replace(/</g, '\\u003c')
39
+ // prefers-color-scheme can change after this runs and before ThemeMode hydrates, so follow it and swap
40
+ // the mode instead of leaving the stale one beside it; `dark light` breaks every class-scoped mode selector.
36
41
  const themeScript = `
37
- const preference = localStorage.getItem('theme-preference') || 'system';
38
- const value = preference === 'system'
39
- ? matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'
40
- : preference;
41
- document.documentElement.classList.add(value);
42
- if (['dark', 'light'].includes(value)) document.documentElement.style.colorScheme = value;`
42
+ const root = document.documentElement;
43
+ const darkScheme = matchMedia('(prefers-color-scheme:dark)');
44
+ const applyTheme = () => {
45
+ const preference = localStorage.getItem('theme-preference') || 'system';
46
+ const value = preference === 'system' ? darkScheme.matches ? 'dark' : 'light' : preference;
47
+ root.classList.remove('dark', 'light');
48
+ root.classList.add(value);
49
+ root.style.colorScheme = ['dark', 'light'].includes(value) ? value : '';
50
+ };
51
+ applyTheme();
52
+ darkScheme.addEventListener('change', applyTheme);`
43
53
  ---
44
54
 
45
55
  <!doctype html>
@@ -54,12 +64,17 @@ if (['dark', 'light'].includes(value)) document.documentElement.style.colorSchem
54
64
  {!bare && import.meta.env.PROD && <Fragment set:html={islandScriptSlot} />}
55
65
  <link blocking="render" rel="preload" as="font" href="/fonts/GeistVariable.woff2" crossorigin="anonymous" />
56
66
  <link blocking="render" rel="preload" as="font" href="/fonts/IBMPlexMono-Regular.woff2" crossorigin="anonymous" />
57
- {(site.locale !== 'en' || basePathname === '/brand') && <link href={`https://fonts.googleapis.com/css2?family=Noto+Sans+${({ ja: 'JP', ko: 'KR', cn: 'SC' } as Record<string, string>)[site.locale] ?? 'TC'}:wght@100..900&display=swap`} rel="stylesheet" />}
67
+ {/* Elsewhere CJK text falls back to the platform's typeface for the page's lang;
68
+ a web CJK family costs about 1 MB per page. The brand page presents Noto Sans
69
+ as the brand's CJK typeface. */}
70
+ {basePathname === '/brand' && <link href={`https://fonts.googleapis.com/css2?family=Noto+Sans+${({ ja: 'JP', ko: 'KR', cn: 'SC' } as Record<string, string>)[site.locale] ?? 'TC'}:wght@100..900&display=swap`} rel="stylesheet" />}
58
71
  <style is:inline blocking="render" set:html={fontCSS}></style>
59
72
  {assets.links.map((href) => <link rel="stylesheet" href={href} />)}
60
73
  {/* client:load islands can hydrate while a streamed body is still parsing.
61
74
  Keep their context in the head, after stylesheet discovery. */}
62
- <script is:inline type="application/json" id={siteContextElementId} set:html={siteContextJSON}></script>
75
+ {/* Islands wait for the dictionary, but the first paint does not: let fonts and stylesheets go first. */}
76
+ {translations && <link rel="preload" as="fetch" href={translations.href} crossorigin="anonymous" fetchpriority="low" />}
77
+ <script is:inline type="application/json" id={siteContextElementId} data-translations={translations?.href} set:html={siteContextJSON}></script>
63
78
  {/* Without the dev id, Vite appends a second copy when an island imports the same CSS, reordering the cascade. */}
64
79
  {assets.styles.map(({ id, content }) => <style is:inline data-vite-dev-id={id} set:html={content}></style>)}
65
80
  <title>{head.title}</title>
@@ -162,6 +162,16 @@ export async function sharedInterfaceKeys(sourceRoot = join(dirname(fileURLToPat
162
162
  return [...keys].sort()
163
163
  }
164
164
 
165
+ /**
166
+ * Shared copy and data modules can hold reader-facing prose that a translation mirrors.
167
+ * Shared components and compiled tooling cannot: their interface strings are reviewed
168
+ * through the localized dictionaries, so a rendering change must not mark every
169
+ * document that uses them as changed English source.
170
+ */
171
+ function isSharedSourceModule(path: string) {
172
+ return !/^dist\//.test(path) && !/\.(?:astro|svelte)$/.test(path) && !/\/common\/(?:translations|locales)\.[jt]s$/.test(path)
173
+ }
174
+
165
175
  /** Follow local prose/data modules without executing them or importing product compiler code. */
166
176
  export async function collectSourceDependencies(files: readonly string[], { siteRoot }: { siteRoot: string }): Promise<Record<string, string>> {
167
177
  const root = resolve(siteRoot)
@@ -171,15 +181,14 @@ export async function collectSourceDependencies(files: readonly string[], { site
171
181
  const visit = async (file: string) => {
172
182
  file = resolve(file).replace(/\.(tw|ja|ko|cn)\.mdx$/, '.mdx')
173
183
  if ((!file.startsWith(root + sep) && !file.startsWith(sharedRoot + sep)) || seen.has(file)) return
174
- // Localized dictionaries have their own review resource. Their translations
175
- // must not masquerade as a change to every article's English source.
176
- if (file.startsWith(sharedRoot + sep) && /\/common\/(?:translations|locales)\.[jt]s$/.test(file.replaceAll('\\', '/'))) return
184
+ if (file.startsWith(sharedRoot + sep) && !isSharedSourceModule(relative(sharedRoot, file).replaceAll('\\', '/'))) return
177
185
  seen.add(file)
178
186
  const source = await readFile(file, 'utf8').catch(() => undefined)
179
187
  if (source === undefined) return
180
188
  const key = file.startsWith(sharedRoot + sep) ? `@master/internal-site/${relative(sharedRoot, file)}` : relative(root, file)
181
189
  collected[key.replaceAll('\\', '/')] = source
182
- const imports = source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)(['"])([^'"\n]+)\1/g)
190
+ // Type-only imports are erased before rendering and carry no reader-facing text.
191
+ const imports = source.replace(/\b(?:import|export)\s+type\b[^'";]*?\bfrom\s*(['"])[^'"\n]+\1/g, '').matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)(['"])([^'"\n]+)\1/g)
183
192
  for (const [, , specifier] of imports) {
184
193
  if (!specifier.startsWith('.') && !specifier.startsWith('~/site/') && !specifier.startsWith('@master/internal-site/')) continue
185
194
  let target: string