upcontent 0.0.0-stage → 0.1.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.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +61 -0
  2. package/.github/workflows/deploy.yml +59 -0
  3. package/.github/workflows/publish.yml +75 -0
  4. package/.github/workflows/reusable-pages.yml +74 -0
  5. package/.upcontent/config.json +85 -0
  6. package/.upcontent/favicon.svg +4 -0
  7. package/.upcontent/logo.svg +6 -0
  8. package/.upcontent/portal.css +139 -0
  9. package/CONTEXT.md +25 -0
  10. package/Makefile +33 -0
  11. package/README.md +132 -2
  12. package/SHOWCASE.mdx +163 -0
  13. package/assets/readme/portal-home.png +0 -0
  14. package/assets/readme/portal-showcase.png +0 -0
  15. package/astro.config.mjs +167 -0
  16. package/customization/config-json.md +100 -0
  17. package/customization/content.md +62 -0
  18. package/customization/environment.md +43 -0
  19. package/customization/index.md +33 -0
  20. package/customization/navigation.md +57 -0
  21. package/customization/site-identity.md +43 -0
  22. package/customization/theme.md +47 -0
  23. package/deployment/github-pages.md +46 -0
  24. package/deployment/index.md +25 -0
  25. package/deployment/npm.md +39 -0
  26. package/deployment/static-hosts.md +34 -0
  27. package/getting-started/consumer-repository.md +156 -0
  28. package/getting-started/first-build.md +78 -0
  29. package/guides/authoring-content.md +105 -0
  30. package/guides/validate-your-site.md +58 -0
  31. package/package.json +40 -4
  32. package/scripts/upcontent-cli.mjs +111 -0
  33. package/scripts/verify-external-build.mjs +32 -0
  34. package/scripts/verify-golden-build.mjs +9 -0
  35. package/src/components/MermaidLoader.astro +304 -0
  36. package/src/components/PaletteShowcase.astro +122 -0
  37. package/src/components/StructuredDataCopy.astro +22 -0
  38. package/src/content/__mocks__/astro-content.ts +7 -0
  39. package/src/content/__mocks__/astro-loaders.ts +3 -0
  40. package/src/content/i18n/en.json +1 -0
  41. package/src/content.config.test.ts +209 -0
  42. package/src/content.config.ts +113 -0
  43. package/src/lib/content-blocklist.test.ts +47 -0
  44. package/src/lib/content-blocklist.ts +55 -0
  45. package/src/lib/doc-links.test.ts +62 -0
  46. package/src/lib/doc-links.ts +31 -0
  47. package/src/lib/mermaid-render.test.ts +42 -0
  48. package/src/lib/mermaid-render.ts +24 -0
  49. package/src/lib/portal-config.test.ts +153 -0
  50. package/src/lib/portal-config.ts +187 -0
  51. package/src/lib/portal-routes.test.ts +62 -0
  52. package/src/lib/portal-routes.ts +59 -0
  53. package/src/lib/product-identity.ts +2 -0
  54. package/src/lib/rehype-callouts.test.ts +69 -0
  55. package/src/lib/rehype-callouts.ts +61 -0
  56. package/src/lib/remark-doc-links.test.ts +50 -0
  57. package/src/lib/remark-doc-links.ts +21 -0
  58. package/src/lib/remark-strip-duplicate-title.test.ts +73 -0
  59. package/src/lib/remark-strip-duplicate-title.ts +37 -0
  60. package/src/lib/remark-structured-data-preview.test.ts +104 -0
  61. package/src/lib/remark-structured-data-preview.ts +66 -0
  62. package/src/lib/remark-wiki-links.test.ts +102 -0
  63. package/src/lib/remark-wiki-links.ts +112 -0
  64. package/src/lib/seo-sitemap.test.ts +37 -0
  65. package/src/lib/seo-sitemap.ts +54 -0
  66. package/src/lib/sidebar.test.ts +142 -0
  67. package/src/lib/sidebar.ts +114 -0
  68. package/src/lib/structured-data-tree.test.ts +75 -0
  69. package/src/lib/structured-data-tree.ts +55 -0
  70. package/src/overrides/Footer.astro +114 -0
  71. package/src/overrides/Head.astro +103 -0
  72. package/src/pages/robots.txt.ts +46 -0
  73. package/src/styles/callouts.css +29 -0
  74. package/src/styles/structured-data-preview.css +95 -0
  75. package/src/upcontent-cli.test.ts +36 -0
  76. package/test-fixtures/external-consumer/.upcontent/config.json +30 -0
  77. package/test-fixtures/external-consumer/.upcontent/favicon.svg +4 -0
  78. package/test-fixtures/external-consumer/.upcontent/logo.svg +4 -0
  79. package/test-fixtures/external-consumer/.upcontent/theme.css +4 -0
  80. package/test-fixtures/external-consumer/.upcontent-renderer/README.md +3 -0
  81. package/test-fixtures/external-consumer/README.md +6 -0
  82. package/test-fixtures/external-consumer/forbidden.md +5 -0
  83. package/test-fixtures/external-consumer/noindex.md +7 -0
  84. package/test-fixtures/external-consumer/public.md +6 -0
  85. package/tsconfig.json +7 -0
  86. package/vitest.config.ts +18 -0
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: Validate your site
3
+ description: Check content, configuration, generated output, and browser behavior before publishing.
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ Use this checklist before opening a pull request or publishing a consumer repository.
9
+
10
+ ## Run the build contract
11
+
12
+ ```sh
13
+ pnpm test
14
+ pnpm check
15
+ make build CONTENT_PATH=.
16
+ make check-golden
17
+ make check-external
18
+ git diff --check
19
+ ```
20
+
21
+ The tests cover content loading, title fallback, navigation, blocklists, wiki links, callouts, Mermaid transformation, and structured previews. The external build confirms that a separate consumer repository works too.
22
+
23
+ ## Check the browser
24
+
25
+ Start the portal locally:
26
+
27
+ ```sh
28
+ make dev CONTENT_PATH=.
29
+ ```
30
+
31
+ Open the pages listed in the [capability showcase](../showcase/) and check:
32
+
33
+ - The logo appears once and the site name is clear.
34
+ - The sidebar groups are understandable and the active page is easy to find.
35
+ - Search, internal links, and the table of contents work.
36
+ - Light and dark themes remain readable.
37
+ - Callouts, Mermaid, JSON, YAML, and CSV examples render correctly.
38
+ - The layout works at desktop and mobile widths.
39
+ - The browser console has no required-asset errors.
40
+ - Each indexable page has a useful title, description, canonical URL, and one top-level heading.
41
+ - `dist/robots.txt` points to the generated sitemap.
42
+ - The page source contains JSON-LD and uses the intended `SITE_URL` and `BASE_PATH`.
43
+ - `seo.enabled` matches the intended portal policy; disabled SEO must produce `noindex, nofollow` and `Disallow: /`.
44
+
45
+ ## Check content boundaries
46
+
47
+ The build should not publish:
48
+
49
+ - `.upcontent/` files
50
+ - Protected repository internals
51
+ - Consumer blocklisted paths
52
+ - Documents with malformed or traversal wiki links
53
+
54
+ When a change affects blocklists or navigation roots, verify both the generated route list and the sidebar. Hiding a link is not enough; excluded content must be absent before parsing.
55
+
56
+ ## Record the result
57
+
58
+ If a check fails, record the source page, route, and browser or build output in the relevant engineering ticket. Do not mark a documentation change complete based only on a successful local page load.
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "upcontent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "type": "module",
4
+ "version": "0.1.1",
5
+ "packageManager": "pnpm@10.20.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/lumamontes/upcontent.git"
9
+ },
10
+ "bin": {
11
+ "upcontent": "scripts/upcontent-cli.mjs"
12
+ },
13
+ "scripts": {
14
+ "dev": "astro dev",
15
+ "build": "astro build",
16
+ "check": "astro check",
17
+ "test": "vitest run"
18
+ },
19
+ "dependencies": {
20
+ "@astrojs/markdown-remark": "^7.3.1",
21
+ "@astrojs/sitemap": "^3.7.4",
22
+ "@astrojs/starlight": "^0.42.1",
23
+ "@fontsource/poppins": "^5.3.0",
24
+ "astro": "^7.3.3",
25
+ "js-yaml": "^4.3.2",
26
+ "mermaid": "^11.0.0",
27
+ "papaparse": "^5.7.0",
28
+ "unified": "^11.0.5",
29
+ "unist-util-visit": "^5.0.0",
30
+ "zod": "^3.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@astrojs/check": "^0.9.10",
34
+ "@types/js-yaml": "^4.0.9",
35
+ "@types/papaparse": "^5.5.2",
36
+ "rehype-stringify": "^10.0.1",
37
+ "remark-parse": "^11.0.0",
38
+ "remark-rehype": "^11.1.2",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^2.0.0"
41
+ }
42
+ }
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { spawnSync } from 'node:child_process'
7
+
8
+ const command = process.argv[2] ?? 'help'
9
+ const force = process.argv.includes('--force')
10
+ const root = process.cwd()
11
+ const rendererRoot = dirname(dirname(fileURLToPath(import.meta.url)))
12
+
13
+ const config = `{
14
+ "site": {
15
+ "title": "Documentation",
16
+ "description": "Project documentation.",
17
+ "logo": {
18
+ "src": ".upcontent/logo.svg",
19
+ "alt": "Documentation"
20
+ },
21
+ "favicon": ".upcontent/favicon.svg"
22
+ },
23
+ "repo": {
24
+ "url": "https://github.com/ORG/REPOSITORY"
25
+ },
26
+ "theme": {
27
+ "customCss": [".upcontent/theme.css"]
28
+ }
29
+ }
30
+ `
31
+
32
+ const theme = `:root {
33
+ --sl-color-accent: #0f766e;
34
+ --sl-color-accent-high: #115e59;
35
+ }
36
+ `
37
+
38
+ const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72">
39
+ <rect width="56" height="56" x="8" y="8" rx="16" fill="#0f766e"/>
40
+ <path d="M20 24h32v8H20zm0 16h24v8H20z" fill="#fff"/>
41
+ </svg>
42
+ `
43
+
44
+ const favicon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
45
+ <rect width="64" height="64" rx="16" fill="#0f766e"/>
46
+ <path d="M18 20h28v8H18zm0 16h21v8H18z" fill="#fff"/>
47
+ </svg>
48
+ `
49
+
50
+ const workflow = `name: Publish documentation
51
+
52
+ on:
53
+ push:
54
+ branches: [main]
55
+ workflow_dispatch:
56
+
57
+ jobs:
58
+ publish:
59
+ uses: lumamontes/upcontent/.github/workflows/reusable-pages.yml@main
60
+ permissions:
61
+ contents: read
62
+ pages: write
63
+ id-token: write
64
+ `
65
+
66
+ function init() {
67
+ const files = [
68
+ ['.upcontent/config.json', config],
69
+ ['.upcontent/theme.css', theme],
70
+ ['.upcontent/logo.svg', logo],
71
+ ['.upcontent/favicon.svg', favicon],
72
+ ['.github/workflows/deploy-docs.yml', workflow],
73
+ ]
74
+ const collisions = files.filter(([relativePath]) => existsSync(join(root, relativePath)))
75
+ if (collisions.length > 0 && !force) {
76
+ for (const [relativePath] of collisions) {
77
+ console.error(`Refusing to overwrite ${relativePath}. Use --force to replace generated files.`)
78
+ }
79
+ process.exitCode = 1
80
+ return
81
+ }
82
+ for (const [relativePath, content] of files) {
83
+ const absolutePath = join(root, relativePath)
84
+ mkdirSync(dirname(absolutePath), { recursive: true })
85
+ writeFileSync(absolutePath, content)
86
+ console.log(`created ${relativePath}`)
87
+ }
88
+ console.log('\nNext steps:')
89
+ console.log('1. Replace ORG/REPOSITORY in .upcontent/config.json.')
90
+ console.log('2. Review the generated identity and theme files.')
91
+ console.log('3. Run `pnpm dlx upcontent dev` to preview locally.')
92
+ console.log('4. Push to main to publish through GitHub Actions.')
93
+ }
94
+
95
+ function run(name, args) {
96
+ const result = spawnSync(name, args, { cwd: rendererRoot, stdio: 'inherit' })
97
+ if (result.error) throw result.error
98
+ return result.status === 0
99
+ }
100
+
101
+ if (command === 'init') init()
102
+ else if (command === 'dev') {
103
+ process.exitCode = run('make', ['dev', `CONTENT_PATH=${root}`]) ? 0 : 1
104
+ }
105
+ else if (command === 'check') {
106
+ const passed = run('make', ['build', `CONTENT_PATH=${root}`])
107
+ process.exitCode = passed ? 0 : 1
108
+ }
109
+ else {
110
+ console.log('Usage: upcontent <init|dev|check> [--force]')
111
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+
3
+ const html = readFileSync('dist/index.html', 'utf8')
4
+ const required = [
5
+ 'External Consumer',
6
+ 'https://github.com/example/external-consumer',
7
+ '/upcontent-assets/favicon.svg',
8
+ ]
9
+
10
+ for (const value of required) {
11
+ if (!html.includes(value)) throw new Error(`External consumer artifact is missing: ${value}`)
12
+ }
13
+
14
+ if (!existsSync('dist/upcontent-assets/favicon.svg')) throw new Error('External consumer favicon was not copied')
15
+ if (!html.includes('alt="External Consumer"')) throw new Error('External consumer logo was not rendered')
16
+ if (!html.includes('application/ld+json')) throw new Error('External consumer JSON-LD metadata is missing')
17
+ if (!existsSync('dist/robots.txt')) throw new Error('External consumer robots.txt is missing')
18
+ if (!existsSync('dist/sitemap-index.xml')) throw new Error('External consumer sitemap is missing')
19
+ if (!readFileSync('dist/noindex/index.html', 'utf8').includes('noindex, nofollow')) throw new Error('External consumer noindex page is not marked noindex')
20
+ const sitemap = readFileSync('dist/sitemap-0.xml', 'utf8')
21
+ if (!sitemap.includes('<loc>https://docs.example.com/</loc>')) throw new Error('External consumer root route is missing from sitemap')
22
+ if (sitemap.includes('/noindex/')) throw new Error('External consumer noindex page leaked into sitemap')
23
+ if (existsSync('dist/forbidden/index.html')) throw new Error('External consumer blocklist leaked forbidden.md')
24
+ if (existsSync('dist/assets/readme')) throw new Error('Golden README assets leaked into external consumer build')
25
+ if (existsSync('dist/upcontent-renderer/readme/index.html')) {
26
+ throw new Error('External consumer build leaked renderer checkout content')
27
+ }
28
+ if (!existsSync('dist/pagefind/pagefind.js')) throw new Error('External consumer Pagefind output is missing')
29
+
30
+ const cssFiles = readdirSync('dist/_astro').filter(file => file.endsWith('.css'))
31
+ const hasConsumerCss = cssFiles.some(file => readFileSync(`dist/_astro/${file}`, 'utf8').includes('#0f766e'))
32
+ if (!hasConsumerCss) throw new Error('External consumer CSS was not included')
@@ -0,0 +1,9 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+
3
+ const html = readFileSync('dist/index.html', 'utf8')
4
+ const requiredAssets = ['assets/readme/portal-home.png', 'assets/readme/portal-showcase.png']
5
+
6
+ for (const asset of requiredAssets) {
7
+ if (!existsSync(`dist/${asset}`)) throw new Error(`Golden build is missing: dist/${asset}`)
8
+ if (!html.includes(asset)) throw new Error(`Golden build does not reference: ${asset}`)
9
+ }
@@ -0,0 +1,304 @@
1
+ ---
2
+ ---
3
+
4
+ <script>
5
+ import { renderMermaidDiagram } from '../lib/mermaid-render'
6
+
7
+ function mermaidTheme() {
8
+ return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'default'
9
+ }
10
+
11
+ let mermaidPromise: Promise<typeof import('mermaid').default> | undefined
12
+
13
+ function loadMermaid() {
14
+ mermaidPromise ??= import('mermaid').then(({ default: mermaid }) => {
15
+ mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
16
+ return mermaid
17
+ })
18
+ return mermaidPromise
19
+ }
20
+
21
+ let scale = 1
22
+ let translateX = 0
23
+ let translateY = 0
24
+ let dialogEl: HTMLDialogElement | null = null
25
+ let contentEl: HTMLDivElement | null = null
26
+
27
+ function applyTransform() {
28
+ if (contentEl) contentEl.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`
29
+ }
30
+
31
+ function resetTransform() {
32
+ scale = 1
33
+ translateX = 0
34
+ translateY = 0
35
+ applyTransform()
36
+ }
37
+
38
+ function ensureDialog() {
39
+ if (dialogEl) return
40
+
41
+ dialogEl = document.createElement('dialog')
42
+ dialogEl.className = 'mermaid-fullscreen-dialog'
43
+
44
+ const closeBtn = document.createElement('button')
45
+ closeBtn.type = 'button'
46
+ closeBtn.className = 'mermaid-fullscreen-close'
47
+ closeBtn.setAttribute('aria-label', 'Fechar')
48
+ closeBtn.textContent = '✕'
49
+ closeBtn.addEventListener('click', () => dialogEl?.close())
50
+
51
+ const viewport = document.createElement('div')
52
+ viewport.className = 'mermaid-fullscreen-viewport'
53
+
54
+ contentEl = document.createElement('div')
55
+ contentEl.className = 'mermaid-fullscreen-content'
56
+ viewport.appendChild(contentEl)
57
+
58
+ dialogEl.append(closeBtn, viewport)
59
+ document.body.appendChild(dialogEl)
60
+
61
+ dialogEl.addEventListener('click', e => {
62
+ if (e.target === dialogEl) dialogEl?.close()
63
+ })
64
+ dialogEl.addEventListener('close', resetTransform)
65
+
66
+ viewport.addEventListener(
67
+ 'wheel',
68
+ e => {
69
+ e.preventDefault()
70
+ const delta = e.deltaY > 0 ? -0.1 : 0.1
71
+ scale = Math.min(Math.max(scale + delta, 0.2), 5)
72
+ applyTransform()
73
+ },
74
+ { passive: false },
75
+ )
76
+
77
+ let dragging = false
78
+ let lastX = 0
79
+ let lastY = 0
80
+ viewport.addEventListener('pointerdown', e => {
81
+ dragging = true
82
+ lastX = e.clientX
83
+ lastY = e.clientY
84
+ viewport.setPointerCapture(e.pointerId)
85
+ })
86
+ viewport.addEventListener('pointermove', e => {
87
+ if (!dragging) return
88
+ translateX += e.clientX - lastX
89
+ translateY += e.clientY - lastY
90
+ lastX = e.clientX
91
+ lastY = e.clientY
92
+ applyTransform()
93
+ })
94
+ viewport.addEventListener('pointerup', () => { dragging = false })
95
+ viewport.addEventListener('pointerleave', () => { dragging = false })
96
+ }
97
+
98
+ function openFullscreen(svg: string) {
99
+ ensureDialog()
100
+ if (!dialogEl || !contentEl) return
101
+ contentEl.innerHTML = svg
102
+ resetTransform()
103
+ dialogEl.showModal()
104
+ }
105
+
106
+ async function copyToClipboard(button: HTMLButtonElement, text: string) {
107
+ await navigator.clipboard.writeText(text)
108
+ const original = button.textContent
109
+ button.textContent = 'Copiado!'
110
+ setTimeout(() => { button.textContent = original }, 2000)
111
+ }
112
+
113
+ async function processDiagrams() {
114
+ const nodes = Array.from(document.querySelectorAll<HTMLElement>('div.mermaid'))
115
+ if (nodes.length === 0) return
116
+
117
+ const mermaid = await loadMermaid()
118
+ for (const node of nodes) {
119
+ // <br/> inside a div becomes a DOM element before textContent reads it — não deveria
120
+ // acontecer (remarkMermaid já normaliza), mas o replace aqui é defensivo.
121
+ const source = (node.textContent ?? '').replace(/ /g, ' ')
122
+ const result = await renderMermaidDiagram(mermaid, source)
123
+
124
+ if (result.status === 'ok') {
125
+ const container = document.createElement('div')
126
+ container.className = 'mermaid-container'
127
+ container.dataset.mermaidSource = source
128
+
129
+ const copyBtn = document.createElement('button')
130
+ copyBtn.type = 'button'
131
+ copyBtn.className = 'mermaid-copy-btn'
132
+ copyBtn.textContent = 'Copiar'
133
+ copyBtn.setAttribute('aria-label', 'Copiar código-fonte do diagrama')
134
+ copyBtn.addEventListener('click', e => {
135
+ e.stopPropagation()
136
+ void copyToClipboard(copyBtn, source)
137
+ })
138
+
139
+ const diagram = document.createElement('div')
140
+ diagram.className = 'mermaid-diagram'
141
+ diagram.innerHTML = result.svg
142
+ diagram.setAttribute('role', 'button')
143
+ diagram.setAttribute('tabindex', '0')
144
+ diagram.setAttribute('aria-label', 'Abrir diagrama em tela cheia')
145
+ diagram.addEventListener('click', () => openFullscreen(diagram.innerHTML))
146
+ diagram.addEventListener('keydown', e => {
147
+ if (e.key === 'Enter' || e.key === ' ') {
148
+ e.preventDefault()
149
+ openFullscreen(diagram.innerHTML)
150
+ }
151
+ })
152
+
153
+ container.append(copyBtn, diagram)
154
+ node.replaceWith(container)
155
+ } else {
156
+ const errorBox = document.createElement('div')
157
+ errorBox.className = 'mermaid-error'
158
+ errorBox.dataset.mermaidSource = source
159
+
160
+ const message = document.createElement('p')
161
+ message.className = 'mermaid-error-message'
162
+ message.textContent = `Erro ao renderizar diagrama Mermaid: ${result.message}`
163
+
164
+ const pre = document.createElement('pre')
165
+ pre.className = 'mermaid-error-source'
166
+ pre.textContent = result.source
167
+
168
+ errorBox.append(message, pre)
169
+ node.replaceWith(errorBox)
170
+ }
171
+ }
172
+ }
173
+
174
+ async function rerenderExistingDiagrams() {
175
+ const containers = Array.from(document.querySelectorAll<HTMLElement>('[data-mermaid-source]'))
176
+ if (containers.length === 0) return
177
+
178
+ const mermaid = await loadMermaid()
179
+ mermaid.initialize({ startOnLoad: false, theme: mermaidTheme() })
180
+ for (const container of containers) {
181
+ const source = container.dataset.mermaidSource
182
+ if (!source) continue
183
+ const result = await renderMermaidDiagram(mermaid, source)
184
+ if (result.status === 'ok') {
185
+ const diagram = container.querySelector('.mermaid-diagram')
186
+ if (diagram) diagram.innerHTML = result.svg
187
+ }
188
+ }
189
+ }
190
+
191
+ if (document.readyState === 'loading') {
192
+ document.addEventListener('DOMContentLoaded', () => void processDiagrams())
193
+ } else {
194
+ void processDiagrams()
195
+ }
196
+
197
+ new MutationObserver(() => void rerenderExistingDiagrams()).observe(document.documentElement, {
198
+ attributes: true,
199
+ attributeFilter: ['data-theme'],
200
+ })
201
+ </script>
202
+
203
+ <style is:global>
204
+ .mermaid-container {
205
+ position: relative;
206
+ }
207
+
208
+ .mermaid-diagram {
209
+ cursor: zoom-in;
210
+ }
211
+
212
+ .mermaid-diagram:focus-visible {
213
+ outline: 2px solid var(--sl-color-accent);
214
+ outline-offset: 0.25rem;
215
+ }
216
+
217
+ .mermaid-copy-btn {
218
+ position: absolute;
219
+ top: 0.5rem;
220
+ right: 0.5rem;
221
+ padding: 0.25rem 0.5rem;
222
+ font-size: var(--sl-text-2xs, 0.75rem);
223
+ border: 1px solid var(--sl-color-gray-5);
224
+ border-radius: 0.25rem;
225
+ background: var(--sl-color-gray-6);
226
+ color: var(--sl-color-gray-2);
227
+ cursor: pointer;
228
+ opacity: 0;
229
+ transition: opacity 0.15s;
230
+ }
231
+ .mermaid-container:hover .mermaid-copy-btn,
232
+ .mermaid-copy-btn:focus-visible {
233
+ opacity: 1;
234
+ }
235
+
236
+ @media (prefers-reduced-motion: reduce) {
237
+ .mermaid-copy-btn {
238
+ transition: none;
239
+ }
240
+ }
241
+
242
+ .mermaid-error {
243
+ border: 1px solid var(--sl-color-red, #c53434);
244
+ border-radius: 0.5rem;
245
+ padding: 1rem;
246
+ }
247
+ .mermaid-error-message {
248
+ color: var(--sl-color-red, #c53434);
249
+ font-weight: 600;
250
+ margin: 0 0 0.5rem;
251
+ }
252
+ .mermaid-error-source {
253
+ white-space: pre-wrap;
254
+ overflow-x: auto;
255
+ margin: 0;
256
+ }
257
+
258
+ .mermaid-fullscreen-dialog {
259
+ padding: 0;
260
+ border: none;
261
+ width: 100vw;
262
+ height: 100vh;
263
+ max-width: 100vw;
264
+ max-height: 100vh;
265
+ background: var(--sl-color-bg, var(--sl-color-black));
266
+ }
267
+ .mermaid-fullscreen-dialog::backdrop {
268
+ background-color: var(--sl-color-backdrop-overlay, rgba(0, 0, 0, 0.6));
269
+ }
270
+
271
+ .mermaid-fullscreen-close {
272
+ position: absolute;
273
+ top: 1rem;
274
+ right: 1rem;
275
+ z-index: 1;
276
+ border: 1px solid var(--sl-color-gray-5);
277
+ border-radius: 0.375rem;
278
+ background: var(--sl-color-gray-6);
279
+ color: var(--sl-color-gray-2);
280
+ width: 2.5rem;
281
+ height: 2.5rem;
282
+ cursor: pointer;
283
+ }
284
+
285
+ .mermaid-fullscreen-viewport {
286
+ width: 100%;
287
+ height: 100%;
288
+ overflow: hidden;
289
+ touch-action: none;
290
+ cursor: grab;
291
+ }
292
+ .mermaid-fullscreen-viewport:active {
293
+ cursor: grabbing;
294
+ }
295
+
296
+ .mermaid-fullscreen-content {
297
+ width: 100%;
298
+ height: 100%;
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: center;
302
+ transform-origin: center center;
303
+ }
304
+ </style>
@@ -0,0 +1,122 @@
1
+ ---
2
+ const palettes = {
3
+ quiet: { label: 'Quiet / default', accent: '#334155', surface: '#f8fafc', ink: '#0f172a' },
4
+ river: { label: 'River', accent: '#0f766e', surface: '#f0fdfa', ink: '#134e4a' },
5
+ signal: { label: 'Signal', accent: '#be123c', surface: '#fff1f2', ink: '#4c0519' },
6
+ }
7
+ ---
8
+
9
+ <div class="palette-showcase" data-palette="quiet" data-palettes={JSON.stringify(palettes)}>
10
+ <div class="palette-controls" role="group" aria-label="Preview palettes">
11
+ {Object.entries(palettes).map(([id, palette], index) => (
12
+ <button
13
+ type="button"
14
+ class="palette-button"
15
+ data-palette-button={id}
16
+ aria-pressed={index === 0 ? 'true' : 'false'}
17
+ >
18
+ {palette.label}
19
+ </button>
20
+ ))}
21
+ </div>
22
+ <div class="palette-preview" aria-live="polite">
23
+ <div class="palette-swatch" data-swatch="accent"></div>
24
+ <div class="palette-swatch" data-swatch="surface"></div>
25
+ <div class="palette-swatch" data-swatch="ink"></div>
26
+ <div class="palette-preview-copy">
27
+ <strong data-palette-label>{palettes.quiet.label}</strong>
28
+ <span>One configuration seam, many possible identities.</span>
29
+ </div>
30
+ </div>
31
+ </div>
32
+
33
+ <script>
34
+ document.querySelectorAll<HTMLElement>('.palette-showcase').forEach(showcase => {
35
+ const palettes: Record<string, { accent: string; surface: string; ink: string; label: string }> = JSON.parse(
36
+ showcase.dataset.palettes ?? '{}',
37
+ )
38
+ const label = showcase.querySelector('[data-palette-label]')
39
+
40
+ showcase.querySelectorAll<HTMLButtonElement>('[data-palette-button]').forEach(button => {
41
+ button.addEventListener('click', () => {
42
+ const paletteId = button.dataset.paletteButton
43
+ const palette = paletteId ? palettes[paletteId] : undefined
44
+ if (!palette) return
45
+ showcase.dataset.palette = paletteId
46
+ showcase.style.setProperty('--palette-accent', palette.accent)
47
+ showcase.style.setProperty('--palette-surface', palette.surface)
48
+ showcase.style.setProperty('--palette-ink', palette.ink)
49
+ if (label) label.textContent = palette.label
50
+ showcase.querySelectorAll('[data-palette-button]').forEach(control => {
51
+ control.setAttribute('aria-pressed', String(control === button))
52
+ })
53
+ })
54
+ })
55
+
56
+ showcase.querySelector('[data-palette-button]')?.dispatchEvent(new Event('click'))
57
+ })
58
+ </script>
59
+
60
+ <style>
61
+ .palette-showcase {
62
+ --palette-accent: #334155;
63
+ --palette-surface: #f8fafc;
64
+ --palette-ink: #0f172a;
65
+ margin: 2rem 0;
66
+ }
67
+
68
+ .palette-controls {
69
+ display: flex;
70
+ flex-wrap: wrap;
71
+ gap: 0.5rem;
72
+ margin-bottom: 1rem;
73
+ }
74
+
75
+ .palette-button {
76
+ border: 1px solid var(--sl-color-gray-5);
77
+ border-radius: 999px;
78
+ background: transparent;
79
+ color: var(--sl-color-text);
80
+ cursor: pointer;
81
+ padding: 0.45rem 0.75rem;
82
+ }
83
+
84
+ .palette-button[aria-pressed='true'] {
85
+ border-color: var(--sl-color-accent);
86
+ background: var(--sl-color-accent);
87
+ color: var(--sl-color-bg);
88
+ }
89
+
90
+ .palette-preview {
91
+ display: grid;
92
+ grid-template-columns: repeat(3, 3rem) 1fr;
93
+ gap: 0.75rem;
94
+ align-items: center;
95
+ border: 1px solid var(--sl-color-gray-5);
96
+ border-radius: 0.5rem;
97
+ padding: 1rem;
98
+ background: var(--palette-surface);
99
+ color: var(--palette-ink);
100
+ }
101
+
102
+ .palette-swatch {
103
+ width: 3rem;
104
+ height: 3rem;
105
+ border: 1px solid color-mix(in srgb, var(--palette-ink), transparent 70%);
106
+ border-radius: 0.35rem;
107
+ }
108
+
109
+ .palette-swatch[data-swatch='accent'] { background: var(--palette-accent); }
110
+ .palette-swatch[data-swatch='surface'] { background: var(--palette-surface); }
111
+ .palette-swatch[data-swatch='ink'] { background: var(--palette-ink); }
112
+
113
+ .palette-preview-copy {
114
+ display: grid;
115
+ gap: 0.25rem;
116
+ }
117
+
118
+ @media (max-width: 560px) {
119
+ .palette-preview { grid-template-columns: repeat(3, 2.25rem) 1fr; }
120
+ .palette-swatch { width: 2.25rem; height: 2.25rem; }
121
+ }
122
+ </style>