Skip to content

Commit 1a12f62

Browse files
committed
Add theme color preset picker for purestack frontend.
1 parent 21f1596 commit 1a12f62

25 files changed

Lines changed: 1028 additions & 127 deletions

‎frontend/README.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,25 @@ palette variables. Page-specific layout and decorative styles use `styleBuilder`
7272
and `themes.forEach`, with framework breakpoint tokens. They are emitted into
7373
`site.css` and `site.dark.css`; there is no separate handwritten CSS asset.
7474

75-
To change the identity, edit the hues in `theme/studioSkinShared.ts`. To add a
75+
The palette icon before the theme toggle offers Cobalt, Violet, Teal, Emerald,
76+
Amber, and Rose. Edit `theme/studioColorPresets.ts` to adjust each complete color
77+
family: neutrals, accents, companion colors, and semantic roles. Each preset
78+
coordinates page backgrounds, reading surfaces, text, borders, controls, shadows,
79+
and highlights in both modes. Status colors retain their familiar meanings.
80+
`theme/studioSurfaceProfiles.ts` defines each preset's lightness steps for the
81+
page, cards, inset surfaces, and controls, plus border strength, saturation, and
82+
shadow density. Dark presets range from crisp slate to a brighter warm studio.
83+
Documentation derives its colors from these same roles, so its code panels and
84+
copy controls keep their own layers instead of sharing one flat reading surface.
85+
`studioColorStyles.ts` emits the changed palette variables and documentation
86+
color variables. A tiny inline startup script before the
87+
stylesheet links restores `purestack-site-color` from local storage. The picker
88+
uses the normal `PageScript` asset pipeline for its separately bundled runtime,
89+
supports native radio keyboard controls, and follows changes from other tabs.
90+
If storage is unavailable, the choice still applies for the current visit.
91+
92+
To change a selectable identity, edit its preset; `theme/studioSkinShared.ts`
93+
defines the skin's baseline hues and shared typography. To add a
7694
reusable section,
7795
compose existing primitives in its own file under `components/`, register its
7896
definition in `studioComponents.ts`, and register only the extra layout styles
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { defineComponent, html } from 'regor'
2+
import { studioColorPresets } from '../theme/studioColorPresets'
3+
4+
const template = html`<details class="studio-color-picker">
5+
<summary class="studio-color-picker__trigger" aria-label="Site color" title="Choose site color">
6+
<Icon name="tabler:palette" aria-hidden="true"/>
7+
</summary>
8+
<fieldset class="studio-color-picker__panel">
9+
<legend>Site color</legend>
10+
<p>Choose your site’s color palette.</p>
11+
<div class="studio-color-picker__options">
12+
${studioColorPresets.map(renderColorOption).join('')}
13+
</div>
14+
<span class="studio-color-picker__note">Saved on this device.</span>
15+
</fieldset>
16+
</details><PageScript src="/color-picker.ts"/>`
17+
18+
export function defineStudioColorPickerComponent() {
19+
return defineComponent(template)
20+
}
21+
22+
function renderColorOption(preset: (typeof studioColorPresets)[number]) {
23+
return html`<label class="studio-color-picker__option" data-site-color-option="${preset.id}">
24+
<input type="radio" name="studio-site-color" value="${preset.id}"/>
25+
<span class="studio-color-picker__swatch" aria-hidden="true"><Icon name="tabler:check"/></span>
26+
<span>${preset.label}</span>
27+
</label>`
28+
}

‎frontend/components/studioComponents.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineStudioActivityChartComponent } from './studioActivityChart'
22
import { defineStudioBrandComponent } from './studioBrand'
33
import { defineStudioCommandComponent } from './studioCommand'
4+
import { defineStudioColorPickerComponent } from './studioColorPicker'
45
import { defineStudioCopyComponent } from './studioCopy'
56
import { defineStudioFaqComponent } from './studioFaq'
67
import { defineStudioFeatureComponent } from './studioFeature'
@@ -11,6 +12,7 @@ import { defineStudioWorkbenchComponent } from './studioWorkbench'
1112
export function defineStudioComponents() {
1213
return {
1314
studioBrand: defineStudioBrandComponent(),
15+
studioColorPicker: defineStudioColorPickerComponent(),
1416
studioFeature: defineStudioFeatureComponent(),
1517
studioPackage: defineStudioPackageComponent(),
1618
studioRegorConnection: defineStudioRegorConnectionComponent(),

‎frontend/docs/previewTemplate.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ const frameScript = `
2929
document.querySelectorAll('.site-guide-sample details.sign-in').forEach(menu=>menu.open=true);
3030
document.querySelectorAll('.site-guide-sample .consent__banner').forEach(banner=>{banner.hidden=false;banner.removeAttribute('aria-hidden')});
3131
if(window.parent!==window){
32-
const sync=()=>{document.documentElement.dataset.theme=parent.document.documentElement.dataset.theme||'light';document.documentElement.setAttribute('data-theme-ready','true')};
32+
const sync=()=>{document.documentElement.dataset.theme=parent.document.documentElement.dataset.theme||'light';document.documentElement.dataset.siteColor=parent.document.documentElement.dataset.siteColor||'cobalt';document.documentElement.setAttribute('data-theme-ready','true')};
3333
sync();
34-
new MutationObserver(sync).observe(parent.document.documentElement,{attributes:true,attributeFilter:['data-theme']});
34+
new MutationObserver(sync).observe(parent.document.documentElement,{attributes:true,attributeFilter:['data-theme','data-site-color']});
3535
const minimumHeight=frameElement ? parseFloat(frameElement.style.height)||0 : 0;
3636
const resize=()=>{if(frameElement)frameElement.style.height=Math.max(minimumHeight,Math.ceil(document.body.getBoundingClientRect().height))+'px'};
3737
new ResizeObserver(resize).observe(document.body);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
DEFAULT_SITE_COLOR,
3+
isSiteColor,
4+
SITE_COLOR_STORAGE_KEY,
5+
studioColorPresets,
6+
type SiteColor,
7+
} from '../theme/studioColorPresets'
8+
9+
const root = document.documentElement
10+
const pickerSelector = '.studio-color-picker'
11+
12+
function applyColor(color: SiteColor) {
13+
root.dataset.siteColor = color
14+
const label = studioColorPresets.find((preset) => preset.id === color)?.label
15+
for (const picker of document.querySelectorAll<HTMLDetailsElement>(
16+
pickerSelector,
17+
)) {
18+
picker
19+
.querySelector('summary')
20+
?.setAttribute('aria-label', `Site color: ${label}`)
21+
for (const input of picker.querySelectorAll<HTMLInputElement>(
22+
'input[type="radio"]',
23+
)) {
24+
input.checked = input.value === color
25+
}
26+
}
27+
}
28+
29+
function saveColor(color: SiteColor) {
30+
applyColor(color)
31+
let message = 'Saved on this device.'
32+
try {
33+
localStorage.setItem(SITE_COLOR_STORAGE_KEY, color)
34+
} catch {
35+
message = 'Applied for this visit.'
36+
}
37+
for (const note of document.querySelectorAll('.studio-color-picker__note')) {
38+
note.textContent = message
39+
}
40+
}
41+
42+
function closePickers(returnFocus = false) {
43+
for (const picker of document.querySelectorAll<HTMLDetailsElement>(
44+
`${pickerSelector}[open]`,
45+
)) {
46+
picker.open = false
47+
if (returnFocus) picker.querySelector('summary')?.focus()
48+
}
49+
}
50+
51+
document.addEventListener('change', (event) => {
52+
const input = event.target
53+
if (!(input instanceof HTMLInputElement) || !input.closest(pickerSelector))
54+
return
55+
if (isSiteColor(input.value)) saveColor(input.value)
56+
})
57+
58+
document.addEventListener('click', (event) => {
59+
if (event.target instanceof Element && !event.target.closest(pickerSelector))
60+
closePickers()
61+
})
62+
63+
document.addEventListener('keydown', (event) => {
64+
if (event.key === 'Escape') closePickers(true)
65+
})
66+
67+
window.addEventListener('storage', (event) => {
68+
if (event.key !== SITE_COLOR_STORAGE_KEY && event.key !== null) return
69+
applyColor(isSiteColor(event.newValue) ? event.newValue : DEFAULT_SITE_COLOR)
70+
})
71+
72+
const initial = root.dataset.siteColor
73+
applyColor(isSiteColor(initial) ? initial : DEFAULT_SITE_COLOR)

‎frontend/purestack.studio/components/header.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<TopBar tone="neutral" variant="surface">
2+
<template #before-actions><StudioColorPicker/></template>
23
<template #actions>
34
<a
45
class="topbar__icon"

‎frontend/purestack.studio/components/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ nav:
1515

1616
Explore all 67 public components and browser entry points. Each guide brings together working examples, the source behind them, behavior guidance and a focused API reference.
1717

18-
<Panel tone="accent" variant="surface" bodyClass="p-4">
18+
<Panel tone="accent" variant="surface" bodyClass="p-4" class="mb-5">
1919
<SectionHeader
2020
title="Find the right piece"
2121
titleTag="h2"

‎frontend/purestack.studio/components/site/top-bar/top-bar.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ TopBar renders a fixed doc-nav-toggle ID and should appear once per document. It
231231

232232
<div class="api-contract">
233233
<p>
234-
Use the actions slot to add links or controls beside the theme toggle.
234+
Use the before-actions slot to add controls before the theme toggle, and
235+
the actions slot to add links or controls after it.
235236
Register defineTopBarComponents and the selected logo component,
236237
SearchBox, ThemeToggle, SignIn, Flex and Icon families. Standard SSG
237238
registration provides these dependencies.

‎frontend/purestack.studio/guides/header.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<TopBar tone="neutral" variant="surface">
2+
<template #before-actions><StudioColorPicker/></template>
23
<template #actions>
34
<a
45
class="topbar__icon"

‎frontend/purestack.studio/guides/styling/utility-css-classes.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ nav:
1515

1616
PureStack generates a small vocabulary of reusable CSS classes alongside its component styles. Use them in MDX markup, in a component's `class` or `bodyClass` prop, or in a TypeScript `html` template. They are generated for each configured theme, so classes that use palette colors or font sizes follow the active skin.
1717

18-
<Panel tone="info" bodyClass="p-3">
18+
<Panel tone="info" bodyClass="p-3" class="mb-5">
1919
<p class="text-eyebrow mt-0">Release note</p>
2020
<h2 class="mt-0 mb-2">A clearer way to build</h2>
2121
<p class="mb-0">Spacing and type follow the site's theme.</p>
2222
</Panel>
2323

2424
```mdx
25-
<Panel tone="info" bodyClass="p-3">
25+
<Panel tone="info" bodyClass="p-3" class="mb-5">
2626
<p class="text-eyebrow mt-0">Release note</p>
2727
<h2 class="mt-0 mb-2">A clearer way to build</h2>
2828
<p class="mb-0">Spacing and type follow the site's theme.</p>

0 commit comments

Comments
 (0)