From 21f1596a284d80d7b7e7a7e93b1deb6fa3169064 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 20 Oct 2026 17:26:18 +0200 Subject: [PATCH 1/5] Improve doc content readability. --- frontend/docs/documentationLinks.ts | 20 ++ .../purestack.studio/components/header.mdx | 26 +- frontend/purestack.studio/guides/header.mdx | 26 +- frontend/purestack.studio/siteConfig.json | 3 +- frontend/studioPlugin.ts | 6 + frontend/theme/documentationColors.ts | 27 ++ frontend/theme/documentationDetailStyles.ts | 275 ++++++++++++++++++ frontend/theme/documentationStyles.ts | 261 +++++++++++++++++ 8 files changed, 634 insertions(+), 10 deletions(-) create mode 100644 frontend/docs/documentationLinks.ts create mode 100644 frontend/theme/documentationColors.ts create mode 100644 frontend/theme/documentationDetailStyles.ts create mode 100644 frontend/theme/documentationStyles.ts 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/purestack.studio/components/header.mdx b/frontend/purestack.studio/components/header.mdx index 3879edb5..49720bfa 100644 --- a/frontend/purestack.studio/components/header.mdx +++ b/frontend/purestack.studio/components/header.mdx @@ -1,12 +1,30 @@ - + diff --git a/frontend/purestack.studio/guides/header.mdx b/frontend/purestack.studio/guides/header.mdx index 3879edb5..49720bfa 100644 --- a/frontend/purestack.studio/guides/header.mdx +++ b/frontend/purestack.studio/guides/header.mdx @@ -1,12 +1,30 @@ - + diff --git a/frontend/purestack.studio/siteConfig.json b/frontend/purestack.studio/siteConfig.json index 98996576..b18f976d 100644 --- a/frontend/purestack.studio/siteConfig.json +++ b/frontend/purestack.studio/siteConfig.json @@ -28,8 +28,7 @@ "includeIndex": true, "sortBy": "order", "roots": ["guides", "components"], - "variant": "spotlight", - "class": "spotlight-from-bottom-right" + "variant": "flat" }, "sitemap": { "enabled": true, diff --git a/frontend/studioPlugin.ts b/frontend/studioPlugin.ts index 9f6ca1fb..2ea58a13 100644 --- a/frontend/studioPlugin.ts +++ b/frontend/studioPlugin.ts @@ -5,9 +5,11 @@ import { card } from './demoStyle' import { registerApiReferenceStyles } from './docs/apiReferenceStyles' import { registerComponentGuideStyles } from './docs/componentGuide' import { defineDocumentationComponents } from './docs/docsComponents' +import { addDocumentationLinks } from './docs/documentationLinks' import { previewTemplate } from './docs/previewTemplate' import { sitePreviewPages } from './docs/sitePreviews' import { ensureLegalContent } from './ensureLegalContent' +import { registerDocumentationStyles } from './theme/documentationStyles' import { studioSkin } from './theme/studioSkin' import { registerStudioStyles } from './theme/studioStyles' @@ -44,11 +46,15 @@ export const studioPlugin = definePlugin({ }, }, hooks: { + onPageDocument(_context, { document }) { + addDocumentationLinks(document) + }, async onConfigResolved({ config }) { await ensureLegalContent(config.contentDir) registerStudioStyles() registerApiReferenceStyles() registerComponentGuideStyles() + registerDocumentationStyles() }, }, }) diff --git a/frontend/theme/documentationColors.ts b/frontend/theme/documentationColors.ts new file mode 100644 index 00000000..90c07226 --- /dev/null +++ b/frontend/theme/documentationColors.ts @@ -0,0 +1,27 @@ +import type { ThemeMode, ThemePalette } from '@purestack/ts-style' +import { paint, studioHues } from './studioSkinShared' + +export type DocumentationColors = ReturnType + +/** Reading surfaces and their decorative accents, separate from the demo palette. */ +export function createDocumentationColors( + theme: ThemeMode, + palette: ThemePalette, +) { + const neutral = paint(studioHues.neutral) + const dark = theme === 'dark' + const surface = dark ? neutral(0.265, 0.13) : neutral(0.99, 0.015) + const shadow = neutral(0.15, 0.1, dark ? 0.16 : 0.045) + + return { + canvas: dark ? neutral(0.235, 0.12) : neutral(0.975, 0.025), + surface, + ink: dark ? neutral(0.85, 0.08) : neutral(0.32, 0.12), + heading: dark ? neutral(0.93, 0.06) : neutral(0.25, 0.15), + muted: dark ? neutral(0.72, 0.1) : neutral(0.48, 0.12), + border: dark ? neutral(0.36, 0.12) : neutral(0.88, 0.05), + shadow, + tint: `color-mix(in srgb, ${palette.semanticTone.accent.text.default} 6%, ${surface})`, + elevation: `0 3px 12px ${shadow}, 0 16px 40px ${shadow}`, + } +} diff --git a/frontend/theme/documentationDetailStyles.ts b/frontend/theme/documentationDetailStyles.ts new file mode 100644 index 00000000..6b0d9536 --- /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 { muted, surface, 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(muted) + .background(surface) + .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..1260c2c5 --- /dev/null +++ b/frontend/theme/documentationStyles.ts @@ -0,0 +1,261 @@ +import type { Style } from '@purestack/ts-css' +import { styleBuilder, themes, type ThemePalette } from '@purestack/ts-style' +import { + createDocumentationColors, + 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) => { + const colors = createDocumentationColors(theme, palette) + 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 { surface, 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(surface) + .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) +} From 1a12f62ac4073dfb4db5f05772fa215c6d3aacc3 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 20 Oct 2026 19:37:20 +0200 Subject: [PATCH 2/5] Add theme color preset picker for purestack frontend. --- frontend/README.md | 20 +- frontend/components/studioColorPicker.ts | 28 +++ frontend/components/studioComponents.ts | 2 + frontend/docs/previewTemplate.ts | 4 +- frontend/purestack.studio/color-picker.ts | 73 +++++++ .../purestack.studio/components/header.mdx | 1 + .../purestack.studio/components/index.mdx | 2 +- .../components/site/top-bar/top-bar.mdx | 3 +- frontend/purestack.studio/guides/header.mdx | 1 + .../guides/styling/utility-css-classes.mdx | 4 +- frontend/purestack.studio/header.mdx | 1 + frontend/studioPlugin.ts | 6 + frontend/theme/documentationColors.ts | 58 ++++-- frontend/theme/documentationDetailStyles.ts | 6 +- frontend/theme/documentationStyles.ts | 12 +- frontend/theme/studioColorHead.ts | 53 +++++ frontend/theme/studioColorPickerStyles.ts | 196 ++++++++++++++++++ frontend/theme/studioColorPresets.ts | 106 ++++++++++ frontend/theme/studioColorStyles.ts | 49 +++++ frontend/theme/studioSkinDark.ts | 165 +++++++++++---- frontend/theme/studioSkinLight.ts | 146 +++++++++---- frontend/theme/studioSkinShared.ts | 11 +- frontend/theme/studioSurfaceProfiles.ts | 195 +++++++++++++++++ .../src/standard/topBar/topBar.ts | 1 + .../src/standard/topBar/topBarStyle.ts | 12 +- 25 files changed, 1028 insertions(+), 127 deletions(-) create mode 100644 frontend/components/studioColorPicker.ts create mode 100644 frontend/purestack.studio/color-picker.ts create mode 100644 frontend/theme/studioColorHead.ts create mode 100644 frontend/theme/studioColorPickerStyles.ts create mode 100644 frontend/theme/studioColorPresets.ts create mode 100644 frontend/theme/studioColorStyles.ts create mode 100644 frontend/theme/studioSurfaceProfiles.ts diff --git a/frontend/README.md b/frontend/README.md index 7d63a5fc..8634f3f5 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -72,7 +72,25 @@ 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. +`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/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 49720bfa..02173b71 100644 --- a/frontend/purestack.studio/components/header.mdx +++ b/frontend/purestack.studio/components/header.mdx @@ -1,4 +1,5 @@ +