@master/internal-site 0.2.3 → 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.
- package/dist/astro.mjs +1868 -8
- package/dist/{localization-LfIbIysE.mjs → localization-IeZXkrNw.mjs} +11 -2
- package/dist/localization.mjs +1 -1
- package/package.json +3 -2
- package/src/astro/image-variants.ts +96 -0
- package/src/astro/index.ts +14 -4
- package/src/astro/island-scripts.ts +31 -0
- package/src/astro/site-translations.ts +31 -0
- package/src/astro/style-assets.ts +2 -2
- package/src/astro/svelte-client.ts +13 -0
- package/src/docs-shell/components/Image.svelte +7 -4
- package/src/docs-shell/components/SearchButton.svelte +5 -3
- package/src/docs-shell/components/SearchDialog.svelte +47 -161
- package/src/docs-shell/components/SearchDialogPanel.svelte +182 -0
- package/src/docs-shell/contexts/search.ts +8 -0
- package/src/docs-shell/contexts/theme-mode.svelte.ts +6 -1
- package/src/docs-shell/scripts/code.ts +9 -4
- package/src/docs-shell/site-dictionary.ts +36 -0
- package/src/docs-shell/types/StaticImage.ts +2 -0
- package/src/examples/components/ArticleTransitionScene.svelte +16 -14
- package/src/home/home-lamp.ts +35 -13
- package/src/home/icons/artwork/render-prerender-dark.webp +0 -0
- package/src/home/icons/artwork/render-prerender-light.webp +0 -0
- package/src/home/icons/artwork/source.json +6 -0
- package/src/home/icons/artwork.ts +3 -0
- package/src/home/icons/catalog.ts +9 -1
- package/src/home/icons/render-prerender.svg +12 -0
- package/src/layouts/Document.astro +28 -11
- package/src/scripts/localization.ts +13 -4
- package/src/styles/blog-article.css +5 -4
- package/src/styles/system/DESIGN.md +1 -1
|
@@ -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
|
|
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 {
|
|
2
|
+
import { loadSiteTranslations } from '../site-dictionary'
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
{
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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">
|
package/src/home/home-lamp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
if (!
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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',
|
|
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',
|
|
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)
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
/**
|
|
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,10 +5,12 @@ 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'
|
|
11
12
|
import type { PageAssets } from '../astro/propagated-assets'
|
|
13
|
+
import { islandScriptSlot } from '../astro/island-scripts'
|
|
12
14
|
import NavigationTransition from './NavigationTransition.astro'
|
|
13
15
|
|
|
14
16
|
interface Props {
|
|
@@ -31,14 +33,23 @@ const languages = locales.length > 1
|
|
|
31
33
|
? { ...Object.fromEntries(locales.map((locale) => [hreflangOf(locale), localizePathname(basePathname, locale, i18n.defaultLocale)])), 'x-default': basePathname }
|
|
32
34
|
: undefined
|
|
33
35
|
const head = resolveHead(metadata, { project: config.name, siteURL: config.origin, pathname: site.pathname, languages })
|
|
34
|
-
|
|
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.
|
|
35
41
|
const themeScript = `
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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);`
|
|
42
53
|
---
|
|
43
54
|
|
|
44
55
|
<!doctype html>
|
|
@@ -50,14 +61,20 @@ if (['dark', 'light'].includes(value)) document.documentElement.style.colorSchem
|
|
|
50
61
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="any" />
|
|
51
62
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
|
52
63
|
<script is:inline blocking="render" set:html={themeScript}></script>
|
|
64
|
+
{!bare && import.meta.env.PROD && <Fragment set:html={islandScriptSlot} />}
|
|
53
65
|
<link blocking="render" rel="preload" as="font" href="/fonts/GeistVariable.woff2" crossorigin="anonymous" />
|
|
54
66
|
<link blocking="render" rel="preload" as="font" href="/fonts/IBMPlexMono-Regular.woff2" crossorigin="anonymous" />
|
|
55
|
-
{
|
|
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" />}
|
|
56
71
|
<style is:inline blocking="render" set:html={fontCSS}></style>
|
|
57
|
-
{/* client:load islands can hydrate while a streamed body is still parsing.
|
|
58
|
-
Their request-local context must already exist, even with cached CSS/JS. */}
|
|
59
|
-
<script is:inline type="application/json" id={siteContextElementId} set:html={siteContextJSON}></script>
|
|
60
72
|
{assets.links.map((href) => <link rel="stylesheet" href={href} />)}
|
|
73
|
+
{/* client:load islands can hydrate while a streamed body is still parsing.
|
|
74
|
+
Keep their context in the head, after stylesheet discovery. */}
|
|
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>
|
|
61
78
|
{/* Without the dev id, Vite appends a second copy when an island imports the same CSS, reordering the cascade. */}
|
|
62
79
|
{assets.styles.map(({ id, content }) => <style is:inline data-vite-dev-id={id} set:html={content}></style>)}
|
|
63
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
|
-
|
|
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
|
-
|
|
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
|