diff --git a/frontend/README.md b/frontend/README.md index 7d63a5fc..b5579ab5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -72,7 +72,30 @@ palette variables. Page-specific layout and decorative styles use `styleBuilder` and `themes.forEach`, with framework breakpoint tokens. They are emitted into `site.css` and `site.dark.css`; there is no separate handwritten CSS asset. -To change the identity, edit the hues in `theme/studioSkinShared.ts`. To add a +The palette icon before the theme toggle offers Cobalt, Violet, Teal, Emerald, +Amber, and Rose. Edit `theme/studioColorPresets.ts` to adjust each complete color +family: neutrals, accents, companion colors, and semantic roles. Each preset +coordinates page backgrounds, reading surfaces, text, borders, controls, shadows, +and highlights in both modes. Status colors retain their familiar meanings. +`theme/studioSurfaceProfiles.ts` defines each preset's lightness steps for the +page, cards, inset surfaces, and controls, plus border strength, saturation, and +shadow density. Dark presets range from crisp slate to a brighter warm studio. +Documentation derives its colors from these same roles, so its code panels and +copy controls keep their own layers instead of sharing one flat reading surface. +`theme/studioLandingColors.ts` keeps the landing page's original light-paper and +deep-slate brightness while applying the selected color family's hues. Its +palette overrides stay within the studio template; documentation retains each +preset's distinct layer contrast. The initial background style uses the same +template-specific palette before the full stylesheets load. +`studioColorStyles.ts` emits the changed palette variables and documentation +color variables. A tiny inline startup script before the +stylesheet links restores `purestack-site-color` from local storage. The picker +uses the normal `PageScript` asset pipeline for its separately bundled runtime, +supports native radio keyboard controls, and follows changes from other tabs. +If storage is unavailable, the choice still applies for the current visit. + +To change a selectable identity, edit its preset; `theme/studioSkinShared.ts` +defines the skin's baseline hues and shared typography. To add a reusable section, compose existing primitives in its own file under `components/`, register its definition in `studioComponents.ts`, and register only the extra layout styles diff --git a/frontend/components/studioColorPicker.ts b/frontend/components/studioColorPicker.ts new file mode 100644 index 00000000..674588d5 --- /dev/null +++ b/frontend/components/studioColorPicker.ts @@ -0,0 +1,28 @@ +import { defineComponent, html } from 'regor' +import { studioColorPresets } from '../theme/studioColorPresets' + +const template = html`
+ + +
+ Site color +

Choose your site’s color palette.

+
+ ${studioColorPresets.map(renderColorOption).join('')} +
+ Saved on this device. +
+
` + +export function defineStudioColorPickerComponent() { + return defineComponent(template) +} + +function renderColorOption(preset: (typeof studioColorPresets)[number]) { + return html`` +} diff --git a/frontend/components/studioComponents.ts b/frontend/components/studioComponents.ts index 556dd94b..6cc86ddd 100644 --- a/frontend/components/studioComponents.ts +++ b/frontend/components/studioComponents.ts @@ -1,6 +1,7 @@ import { defineStudioActivityChartComponent } from './studioActivityChart' import { defineStudioBrandComponent } from './studioBrand' import { defineStudioCommandComponent } from './studioCommand' +import { defineStudioColorPickerComponent } from './studioColorPicker' import { defineStudioCopyComponent } from './studioCopy' import { defineStudioFaqComponent } from './studioFaq' import { defineStudioFeatureComponent } from './studioFeature' @@ -11,6 +12,7 @@ import { defineStudioWorkbenchComponent } from './studioWorkbench' export function defineStudioComponents() { return { studioBrand: defineStudioBrandComponent(), + studioColorPicker: defineStudioColorPickerComponent(), studioFeature: defineStudioFeatureComponent(), studioPackage: defineStudioPackageComponent(), studioRegorConnection: defineStudioRegorConnectionComponent(), diff --git a/frontend/docs/documentationLinks.ts b/frontend/docs/documentationLinks.ts new file mode 100644 index 00000000..1d02a453 --- /dev/null +++ b/frontend/docs/documentationLinks.ts @@ -0,0 +1,20 @@ +/** Add native section permalinks without changing headings in live examples. */ +export function addDocumentationLinks(document: Document) { + if (!document.body.classList.contains('template-doc')) return + if (document.body.classList.contains('preview')) return + const article = document.querySelector('.doc-main > .doc-content') + if (!article) return + + for (const heading of article.children) { + if (!['H2', 'H3'].includes(heading.tagName) || !heading.id) continue + if (heading.querySelector('a')) continue + const link = document.createElement('a') + link.className = 'doc-heading-link' + link.setAttribute('href', `#${encodeURIComponent(heading.id)}`) + link.setAttribute('aria-label', `Link to ${heading.textContent?.trim()}`) + link.setAttribute('title', 'Link to this section') + link.setAttribute('data-pagefind-ignore', '') + link.textContent = '#' + heading.appendChild(link) + } +} diff --git a/frontend/docs/previewTemplate.ts b/frontend/docs/previewTemplate.ts index cc330a1c..f5e2251c 100644 --- a/frontend/docs/previewTemplate.ts +++ b/frontend/docs/previewTemplate.ts @@ -29,9 +29,9 @@ const frameScript = ` document.querySelectorAll('.site-guide-sample details.sign-in').forEach(menu=>menu.open=true); document.querySelectorAll('.site-guide-sample .consent__banner').forEach(banner=>{banner.hidden=false;banner.removeAttribute('aria-hidden')}); if(window.parent!==window){ - const sync=()=>{document.documentElement.dataset.theme=parent.document.documentElement.dataset.theme||'light';document.documentElement.setAttribute('data-theme-ready','true')}; + 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')}; sync(); - new MutationObserver(sync).observe(parent.document.documentElement,{attributes:true,attributeFilter:['data-theme']}); + new MutationObserver(sync).observe(parent.document.documentElement,{attributes:true,attributeFilter:['data-theme','data-site-color']}); const minimumHeight=frameElement ? parseFloat(frameElement.style.height)||0 : 0; const resize=()=>{if(frameElement)frameElement.style.height=Math.max(minimumHeight,Math.ceil(document.body.getBoundingClientRect().height))+'px'}; new ResizeObserver(resize).observe(document.body); diff --git a/frontend/purestack.studio/color-picker.ts b/frontend/purestack.studio/color-picker.ts new file mode 100644 index 00000000..383d5b12 --- /dev/null +++ b/frontend/purestack.studio/color-picker.ts @@ -0,0 +1,73 @@ +import { + DEFAULT_SITE_COLOR, + isSiteColor, + SITE_COLOR_STORAGE_KEY, + studioColorPresets, + type SiteColor, +} from '../theme/studioColorPresets' + +const root = document.documentElement +const pickerSelector = '.studio-color-picker' + +function applyColor(color: SiteColor) { + root.dataset.siteColor = color + const label = studioColorPresets.find((preset) => preset.id === color)?.label + for (const picker of document.querySelectorAll( + pickerSelector, + )) { + picker + .querySelector('summary') + ?.setAttribute('aria-label', `Site color: ${label}`) + for (const input of picker.querySelectorAll( + 'input[type="radio"]', + )) { + input.checked = input.value === color + } + } +} + +function saveColor(color: SiteColor) { + applyColor(color) + let message = 'Saved on this device.' + try { + localStorage.setItem(SITE_COLOR_STORAGE_KEY, color) + } catch { + message = 'Applied for this visit.' + } + for (const note of document.querySelectorAll('.studio-color-picker__note')) { + note.textContent = message + } +} + +function closePickers(returnFocus = false) { + for (const picker of document.querySelectorAll( + `${pickerSelector}[open]`, + )) { + picker.open = false + if (returnFocus) picker.querySelector('summary')?.focus() + } +} + +document.addEventListener('change', (event) => { + const input = event.target + if (!(input instanceof HTMLInputElement) || !input.closest(pickerSelector)) + return + if (isSiteColor(input.value)) saveColor(input.value) +}) + +document.addEventListener('click', (event) => { + if (event.target instanceof Element && !event.target.closest(pickerSelector)) + closePickers() +}) + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closePickers(true) +}) + +window.addEventListener('storage', (event) => { + if (event.key !== SITE_COLOR_STORAGE_KEY && event.key !== null) return + applyColor(isSiteColor(event.newValue) ? event.newValue : DEFAULT_SITE_COLOR) +}) + +const initial = root.dataset.siteColor +applyColor(isSiteColor(initial) ? initial : DEFAULT_SITE_COLOR) diff --git a/frontend/purestack.studio/components/header.mdx b/frontend/purestack.studio/components/header.mdx index 3879edb5..02173b71 100644 --- a/frontend/purestack.studio/components/header.mdx +++ b/frontend/purestack.studio/components/header.mdx @@ -1,12 +1,31 @@ - + + diff --git a/frontend/purestack.studio/components/index.mdx b/frontend/purestack.studio/components/index.mdx index b238f7fc..74cf990d 100644 --- a/frontend/purestack.studio/components/index.mdx +++ b/frontend/purestack.studio/components/index.mdx @@ -15,7 +15,7 @@ nav: 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. - +

- Use the actions slot to add links or controls beside the theme toggle. + Use the before-actions slot to add controls before the theme toggle, and + the actions slot to add links or controls after it. Register defineTopBarComponents and the selected logo component, SearchBox, ThemeToggle, SignIn, Flex and Icon families. Standard SSG registration provides these dependencies. diff --git a/frontend/purestack.studio/guides/header.mdx b/frontend/purestack.studio/guides/header.mdx index 3879edb5..02173b71 100644 --- a/frontend/purestack.studio/guides/header.mdx +++ b/frontend/purestack.studio/guides/header.mdx @@ -1,12 +1,31 @@ - + + diff --git a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx index be9278f0..d5bb99b9 100644 --- a/frontend/purestack.studio/guides/styling/utility-css-classes.mdx +++ b/frontend/purestack.studio/guides/styling/utility-css-classes.mdx @@ -15,14 +15,14 @@ nav: 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. - +

Release note

A clearer way to build

Spacing and type follow the site's theme.

```mdx - +

Release note

A clearer way to build

Spacing and type follow the site's theme.

diff --git a/frontend/purestack.studio/header.mdx b/frontend/purestack.studio/header.mdx index 3655d748..588cd78a 100644 --- a/frontend/purestack.studio/header.mdx +++ b/frontend/purestack.studio/header.mdx @@ -10,6 +10,7 @@
+ ({ ...defineStudioComponents(), ...defineDocumentationComponents(config), @@ -44,11 +50,17 @@ export const studioPlugin = definePlugin({ }, }, hooks: { + onPageDocument(_context, { document }) { + addDocumentationLinks(document) + }, async onConfigResolved({ config }) { await ensureLegalContent(config.contentDir) registerStudioStyles() registerApiReferenceStyles() registerComponentGuideStyles() + registerDocumentationStyles() + registerStudioColorStyles() + registerStudioColorPickerStyles() }, }, }) diff --git a/frontend/theme/documentationColors.ts b/frontend/theme/documentationColors.ts new file mode 100644 index 00000000..dde31774 --- /dev/null +++ b/frontend/theme/documentationColors.ts @@ -0,0 +1,47 @@ +import type { Style } from '@purestack/ts-css' +import type { ThemePalette } from '@purestack/ts-style' + +export type DocumentationColors = ReturnType + +export const documentationColorBindings: DocumentationColors = { + canvas: 'var(--ps-studio-doc-canvas)', + surface: 'var(--ps-studio-doc-surface)', + code: 'var(--ps-studio-doc-code)', + control: 'var(--ps-studio-doc-control)', + ink: 'var(--ps-studio-doc-ink)', + heading: 'var(--ps-studio-doc-heading)', + muted: 'var(--ps-studio-doc-muted)', + border: 'var(--ps-studio-doc-border)', + shadow: 'var(--ps-studio-doc-shadow)', + tint: 'var(--ps-studio-doc-tint)', + elevation: 'var(--ps-studio-doc-elevation)', +} + +/** Documentation uses the preset's actual hierarchy, including inset and control layers. */ +export function createDocumentationColors(palette: ThemePalette) { + const neutral = palette.semanticTone.neutral + const shadow = `color-mix(in srgb, ${neutral.overlay} 22%, transparent)` + + return { + canvas: neutral.canvas, + surface: neutral.surface.rest.background, + code: neutral.surfaceAlt.rest.background, + control: neutral.button.rest.background, + ink: neutral.surfaceAlt.rest.text, + heading: neutral.text.default, + muted: neutral.text.subtle, + border: neutral.border.subtle, + shadow, + tint: palette.semanticTone.accent.surfaceAlt.rest.background, + elevation: palette.effect.softShadow, + } +} + +export function setDocumentationColorVariables( + root: Style, + colors: DocumentationColors, +) { + for (const [name, value] of Object.entries(colors)) { + root.set(`--ps-studio-doc-${name}`, value) + } +} diff --git a/frontend/theme/documentationDetailStyles.ts b/frontend/theme/documentationDetailStyles.ts new file mode 100644 index 00000000..244255b2 --- /dev/null +++ b/frontend/theme/documentationDetailStyles.ts @@ -0,0 +1,275 @@ +import type { Style } from '@purestack/ts-css' +import { BREAKPOINTS, mediaMin, type ThemePalette } from '@purestack/ts-style' +import type { DocumentationColors } from './documentationColors' + +/** Visual landmarks and controls around the documentation's reading column. */ +export function registerDocumentationDetailStyles( + docs: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const article = docs.scope('.doc-main > .doc-content') + + registerTitleAccentStyles(article, palette) + registerSectionLinkStyles(article, palette, colors) + registerNavigationRailStyles(docs, palette, colors) + registerComponentHeaderStyles(article, palette, colors) + registerExampleFrameStyles(article, colors) + registerCodeFrameStyles(article, colors) + registerCopyControlStyles(article, colors) + registerTableFrameStyles(article, colors) + registerAdjacentPageCardStyles(article, colors) + registerAdjacentPageControlStyles(article, palette, colors) +} + +function registerTitleAccentStyles(article: Style, palette: ThemePalette) { + const accent = palette.semanticTone.accent + const secondary = palette.semanticTone.secondary + + article + .select(':scope > h1') + .marginBottom('0.65em') + .letterSpacing('-0.035em') + .fontWeight('650') + + article + .select(':scope > h1::before') + .content('""') + .display('block') + .width('2.75rem') + .height('0.25rem') + .marginBottom('1rem') + .borderRadius('1rem') + .background( + `linear-gradient(90deg, ${accent.text.default}, ${secondary.text.default})`, + ) +} + +function registerSectionLinkStyles( + article: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { border } = colors + const accent = palette.semanticTone.accent + + article + .select(':scope > h2') + .paddingTop('1.25rem') + .borderTop(`1px solid ${border}`) + .marginTop('1.75em') + .letterSpacing('-0.025em') + + article + .select(':scope > :is(h2, h3) > .doc-heading-link') + .display('inline-block') + .marginInlineStart('0.45em') + .color(accent.text.default) + .fontSize('0.7em') + .fontWeight('400') + .textDecoration('none') + .verticalAlign('0.05em') + .opacity('0.45') + + article + .select( + ':scope > :is(h2, h3):hover > .doc-heading-link, .doc-heading-link:focus-visible', + ) + .opacity('1') +} + +function registerNavigationRailStyles( + docs: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { shadow, border, tint } = colors + const accent = palette.semanticTone.accent + + docs.select(':scope > .topbar').boxShadow(`0 1px 12px ${shadow}`) + + // Docked rails recede; mobile drawers retain their opaque reading surface. + docs + .select(':scope:not(.template-doc--nav-drawer) .doc-sidebar > .nav__menu') + .media(mediaMin(BREAKPOINTS.lg)) + .background('transparent') + .borderColor('transparent') + + docs + .select('.doc-toc > .page-toc') + .media(mediaMin(BREAKPOINTS.toc)) + .background('transparent') + .borderColor('transparent') + .borderInlineStart(`1px solid ${border}`) + .borderRadius('0 !important') + + docs + .select( + '.doc-sidebar > .nav__menu .btn[aria-current="page"], .doc-toc > .page-toc .page-toc__link.active', + ) + .background(tint) + .boxShadow(`inset 2px 0 ${accent.text.default}`) + .borderRadius(palette.radii.sm) +} + +function registerComponentHeaderStyles( + article: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { surface, border, elevation, tint } = colors + const accent = palette.semanticTone.accent + const secondary = palette.semanticTone.secondary + + article + .select(':scope > .component-header, :scope > .api-overview') + .padding('clamp(1.25rem, 3vw, 1.75rem)') + .background( + `radial-gradient(ellipse at 100% 0%, color-mix(in srgb, ${secondary.text.default} 8%, transparent), transparent 70%), ${surface}`, + ) + .borderColor(border) + .borderInlineStart(`3px solid ${accent.border.default}`) + .boxShadow(elevation) + .borderRadius('0.875rem') + + article + .select(':scope > .component-header .component-header__links a') + .display('inline-flex') + .alignItems('center') + .gap('0.25em') + .padding('0.3rem 0.65rem') + .background(tint) + .border(`1px solid ${accent.border.subtle}`) + .borderRadius(palette.radii.md) + .textDecoration('none') + + article + .select( + ':scope > .component-header .component-header__links a:is(:hover, :focus-visible)', + ) + .borderColor(accent.border.default) + .background(accent.surface.rest.background) +} + +function registerExampleFrameStyles( + article: Style, + colors: DocumentationColors, +) { + const { border, elevation, tint, heading, muted } = colors + + article + .select(':scope > .component-example') + .marginBlock('1.75rem') + .borderColor(border) + .boxShadow(elevation) + + article + .select(':scope > .component-example > .component-example__heading') + .background(tint) + .color(heading) + .fontSize('0.875rem') + .letterSpacing('normal') + .borderBottomColor(border) + .borderRadius('1rem 1rem 0 0') + + article + .select(':scope > .component-example .component-example__tag') + .padding('0.2rem 0.5rem') + .border(`1px solid ${border}`) + .borderRadius('1rem') + .color(muted) + .letterSpacing('0.06em') +} + +function registerCodeFrameStyles(article: Style, colors: DocumentationColors) { + const { border, elevation } = colors + + article + .select(':scope > pre, .api-property__content > pre, .api-contract > pre') + .padding('1.4rem') + .border(`1px solid ${border}`) + .borderRadius('0.75rem') + .boxShadow(elevation) +} + +function registerCopyControlStyles( + article: Style, + colors: DocumentationColors, +) { + const { heading, control, border } = colors + + article + .select( + ':scope > pre > .code-copy-button, .api-property__content > pre > .code-copy-button, .api-contract > pre > .code-copy-button', + ) + .opacity('0.65') + .pointerEvents('auto') + .color(heading) + .background(control) + .borderColor(border) + + article + .select( + ':scope > pre:is(:hover, :focus-within) > .code-copy-button, .api-property__content > pre:is(:hover, :focus-within) > .code-copy-button, .api-contract > pre:is(:hover, :focus-within) > .code-copy-button', + ) + .opacity('1') +} + +function registerTableFrameStyles(article: Style, colors: DocumentationColors) { + const { shadow, tint } = colors + + article + .select(':scope > .table-scroll') + .borderRadius('0.75rem') + .boxShadow(`0 3px 12px ${shadow}`) + + article.select(':scope > .table-scroll thead').background(tint) +} + +function registerAdjacentPageCardStyles( + article: Style, + colors: DocumentationColors, +) { + const { border, surface, heading, shadow } = colors + + article + .select(':scope > nav[aria-label="Adjacent pages"]') + .marginTop('3.5rem') + .gap('1rem') + .borderTopColor(border) + + article + .select(':scope > nav[aria-label="Adjacent pages"] .btn') + .padding('1.1rem 1.25rem') + .minHeight('5rem') + .fontSize('1rem') + .lineHeight('1.5') + .letterSpacing('normal') + .background(surface) + .color(heading) + .border(`1px solid ${border}`) + .borderRadius('0.75rem') + .boxShadow(`0 3px 12px ${shadow}`) +} + +function registerAdjacentPageControlStyles( + article: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { muted, tint } = colors + const accent = palette.semanticTone.accent + + article + .select(':scope > nav[aria-label="Adjacent pages"] .text-eyebrow') + .fontSize('0.6875rem !important') + .color(muted) + + article + .select( + ':scope > nav[aria-label="Adjacent pages"] .btn:is(:hover, :focus-visible)', + ) + .background(tint) + .borderColor(accent.border.default) + .color(accent.text.default) +} diff --git a/frontend/theme/documentationStyles.ts b/frontend/theme/documentationStyles.ts new file mode 100644 index 00000000..dddd10af --- /dev/null +++ b/frontend/theme/documentationStyles.ts @@ -0,0 +1,267 @@ +import type { Style } from '@purestack/ts-css' +import { styleBuilder, themes, type ThemePalette } from '@purestack/ts-style' +import { + createDocumentationColors, + documentationColorBindings, + setDocumentationColorVariables, + type DocumentationColors, +} from './documentationColors' +import { registerDocumentationDetailStyles } from './documentationDetailStyles' + +/** A reading treatment for the documentation, independent of the demo palette. */ +export function registerDocumentationStyles() { + themes.forEach((theme, palette) => { + setDocumentationColorVariables( + styleBuilder.get(theme).select(':where(:scope)'), + createDocumentationColors(palette), + ) + const colors = documentationColorBindings + const docs = styleBuilder + .get(theme) + .scope('body.template-doc:not(.preview)') + const article = docs.scope('.doc-main > .doc-content') + + docs.select(':scope').background(colors.canvas) + article.select(':scope').maxWidth('72rem') + + registerProseStyles(article, colors) + registerHeadingStyles(article, colors) + registerLinkStyles(article, palette) + registerBlockquoteStyles(article, palette, colors) + registerCodeStyles(article, colors) + registerTableStyles(article, colors) + registerShellSurfaceStyles(docs, colors) + registerNavigationTypographyStyles(docs, colors) + registerNavigationStateStyles(docs, palette, colors) + registerReferenceStyles(article, colors) + registerPropertyStyles(article, colors) + registerMetadataStyles(article, colors) + registerDocumentationDetailStyles(docs, palette, colors) + }) +} + +function registerProseStyles(article: Style, colors: DocumentationColors) { + const { ink } = colors + + // Direct prose children preserve typography utilities inside live examples. + article + .select(':scope > :where(p, ul, ol, blockquote)') + .maxWidth('70ch') + .fontSize('1.0625rem') + .lineHeight('1.75') + .letterSpacing('normal') + .color(ink) + .marginBottom('1.25em') +} + +function registerHeadingStyles(article: Style, colors: DocumentationColors) { + const { heading } = colors + + article + .select(':scope > :where(h1, h2, h3, h4, h5, h6)') + .maxWidth('min(100%, 48rem)') + .color(heading) + .lineHeight('1.3') + .letterSpacing('-0.015em') + .fontWeight('600') + + article + .select(':scope > :where(* + h2, * + h3, * + h4, * + h5, * + h6)') + .marginTop('2em') + .marginBottom('0.75em') +} + +function registerLinkStyles(article: Style, palette: ThemePalette) { + const accent = palette.semanticTone.accent + + article + .select(':scope > :where(p, ul, ol, blockquote) a') + .color(accent.text.default) + .fontWeight('500') + .textDecoration('underline') + .textDecorationColor(accent.border.default) + .textUnderlineOffset('0.18em') + + article + .select(':scope > :where(p, ul, ol, blockquote) a:hover') + .textDecorationColor('currentColor') +} + +function registerBlockquoteStyles( + article: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { surface } = colors + const accent = palette.semanticTone.accent + + article + .select(':scope > blockquote') + .background(surface) + .borderInlineStart(`3px solid ${accent.border.default}`) + .padding('1rem 1.25rem') +} + +function registerCodeStyles(article: Style, colors: DocumentationColors) { + const { code, border, ink } = colors + + article + .select( + ':scope > pre, :scope > .tabs > .tabs__list > .tabs__item > .tabs__panel > .tabs__panel-body > pre, :scope > :where(p, ul, ol, blockquote) code, .api-property__content pre, .api-property__content code, .api-contract pre, .api-contract code', + ) + .fontSize('0.875rem') + .lineHeight('1.7') + .letterSpacing('normal') + .background(code) + .borderColor(border) + .color(ink) + + article + .select( + ':scope > pre code, :scope > .tabs > .tabs__list > .tabs__item > .tabs__panel > .tabs__panel-body > pre code, .api-property__content pre code, .api-contract pre code', + ) + .fontSize('inherit') + .lineHeight('inherit') + .letterSpacing('normal') + .background('transparent') + .border('none') +} + +function registerTableStyles(article: Style, colors: DocumentationColors) { + const { surface, border, ink } = colors + + article + .select(':scope > :where(.table-scroll)') + .background(surface) + .borderColor(border) + .fontSize('0.9375rem') + .lineHeight('1.65') + .letterSpacing('normal') + .color(ink) + + article + .select(':scope > .table-scroll :where(thead tr, code)') + .fontSize('0.8125rem') + .letterSpacing('normal') +} + +function registerShellSurfaceStyles(docs: Style, colors: DocumentationColors) { + const { surface, ink, border } = colors + + docs + .select(':scope > .topbar, .doc-sidebar > .nav__menu, .doc-toc > .page-toc') + .background(surface) + .color(ink) + .borderColor(border) + .boxShadow('none') + .backdropFilter('none') +} + +function registerNavigationTypographyStyles( + docs: Style, + colors: DocumentationColors, +) { + const { muted } = colors + + docs + .select( + '.doc-sidebar > .nav__menu .nav__item .btn, .doc-toc > .page-toc .page-toc__link', + ) + .fontSize('0.875rem') + .lineHeight('1.5') + .letterSpacing('normal') + .color(muted) + + // Override the TOC's fs-sm/fs-xs utilities, which otherwise force 11-12px text. + docs + .select('.doc-toc > .page-toc .page-toc__link') + .fontSize('0.875rem !important') + + docs + .select( + '.doc-sidebar > .nav__menu .nav__header, .doc-toc > .page-toc .page-toc__header', + ) + .fontSize('0.75rem') + .letterSpacing('0.06em') + .color(muted) + .opacity('1') + .marginBottom('0.75rem') +} + +function registerNavigationStateStyles( + docs: Style, + palette: ThemePalette, + colors: DocumentationColors, +) { + const { heading } = colors + const accent = palette.semanticTone.accent + + docs + .select( + '.doc-sidebar > .nav__menu .btn:is(:hover, :focus-visible), .doc-toc > .page-toc .page-toc__link:is(:hover, :focus-visible)', + ) + .color(heading) + .background(accent.surface.rest.background) + + docs + .select( + '.doc-sidebar > .nav__menu .btn[aria-current="page"], .doc-toc > .page-toc .page-toc__link.active', + ) + .color(accent.text.default) + .background(accent.surface.rest.background) + .fontWeight('600') +} + +function registerReferenceStyles(article: Style, colors: DocumentationColors) { + const { surface, border, ink } = colors + + article + .select( + ':scope > .component-header, :scope > .api-overview, :scope > .api-contract', + ) + .background(surface) + .borderColor(border) + .color(ink) + + article + .select( + '.component-header__summary, .api-overview > p, .api-contract > p, .api-property__content > :where(p, ul, ol)', + ) + .maxWidth('70ch') + .fontSize('1.0625rem') + .lineHeight('1.75') + .letterSpacing('normal') + .color(ink) +} + +function registerPropertyStyles(article: Style, colors: DocumentationColors) { + const { border } = colors + + article.select('.api-property').paddingBlock('1.75rem').borderTopColor(border) + + article + .select('.api-property__header code') + .fontSize('0.875rem') + .lineHeight('1.65') + .letterSpacing('normal') + + article.select('.api-property__header h4 code').fontSize('1rem') +} + +function registerMetadataStyles(article: Style, colors: DocumentationColors) { + const { muted } = colors + + article + .select( + '.api-property__type, .api-property__default, .api-index a, .api-note, .component-header__back, .component-header__links a, .component-example__caption', + ) + .fontSize('0.875rem') + .lineHeight('1.65') + .letterSpacing('normal') + + article + .select( + '.api-property__type, .api-property__default, .api-note, .component-header__back, .component-example__caption', + ) + .color(muted) +} diff --git a/frontend/theme/studioColorHead.test.ts b/frontend/theme/studioColorHead.test.ts new file mode 100644 index 00000000..182507e4 --- /dev/null +++ b/frontend/theme/studioColorHead.test.ts @@ -0,0 +1,74 @@ +import { Script } from 'node:vm' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { buildSiteColorInitScript } from './studioColorHead' + +const config = vi.hoisted(() => ({ + defaultColor: 'cobalt', + storageKey: 'purestack-site-color', + ids: ['cobalt', 'violet'], +})) + +vi.mock('./studioColorPresets', () => ({ + get DEFAULT_SITE_COLOR() { + return config.defaultColor + }, + get SITE_COLOR_STORAGE_KEY() { + return config.storageKey + }, + get studioColorPresets() { + return config.ids.map((id) => ({ id })) + }, +})) + +function runStartup(stored: string | null, unavailable = false) { + const attributes = new Map() + const getItem = vi.fn((key: string) => { + expect(key).toBe(config.storageKey) + if (unavailable) throw new Error('Storage unavailable') + return stored + }) + new Script(buildSiteColorInitScript()).runInNewContext({ + localStorage: { getItem }, + document: { + documentElement: { + setAttribute: (name: string, value: string) => + attributes.set(name, value), + }, + }, + }) + expect(getItem).toHaveBeenCalledOnce() + return attributes.get('data-site-color') +} + +describe('site color startup serialization', () => { + beforeEach(() => { + config.defaultColor = 'cobalt' + config.storageKey = 'purestack-site-color' + config.ids = ['cobalt', 'violet'] + }) + + it('keeps script-closing markup inside serialized configuration strings', () => { + const payload = + '