Skip to content

feat(stories): infer a workspace design system from a website (#1463) - #1479

Draft
ClaireGz wants to merge 14 commits into
mainfrom
feat/story-design-system
Draft

feat(stories): infer a workspace design system from a website (#1463)#1479
ClaireGz wants to merge 14 commits into
mainfrom
feat/story-design-system

Conversation

@ClaireGz

@ClaireGz ClaireGz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Closes #1463 (partially — see Not in this PR below).

An admin pastes their company website. nao reads its design signals, proposes a story template, and once the admin validates it, every story in the workspace renders in that design system.

How it works

URL ──▶ static extraction ──▶ LLM mapping ──▶ contrast guard ──▶ draft
                                                                   │
                                                    admin reviews ──┴──▶ published ──▶ every story

1. The contract is the architecture

@nao/shared/story-theme defines a fixed ~30-slot contract: surfaces, ink ramp, typography, shape, chart palette, accent.

This is the key decision. A brand design system is unbounded; a story is not. Stories are built from a bounded component vocabulary, so theming them does not mean replicating a design system, it means filling known slots. That turns an open-ended generative problem into a schema-filling one, which is far more reliable.

storyThemeToCssVars() maps those slots onto the CSS custom properties story components already read (--card, --foreground, --chart-1--chart-7, --radius). StoryThemeProvider sets them on one wrapping element. That is why this PR changes no chart, table or filter component: they inherit.

The wrapper is display: contents, so it adds zero layout. Scoping is deliberate — the story restyles, the surrounding nao chrome does not.

2. The guard is the feature

@nao/shared/story-theme-contrast is where I'd focus review.

Brand palettes are designed for marketing surfaces, not for encoding data. A hero green that sings on a landing page is unreadable as a bar fill; two brand tints that look distinct in a style guide are indistinguishable 4px wide and side by side. If we auto-apply a customer palette without checking, we ship inaccessible charts under their logo.

So every proposed series is validated for contrast against its surface, chroma floor, lightness band, and adjacent-pair separation in OKLab — including under simulated protanopia, deuteranopia and tritanopia. Anything failing is repaired hue-first: lightness and chroma move freely, hue rotates only as a last resort, because hue is what the brand owner actually recognises.

Real examples now covered by tests:

Input Verdict
#712dd0 #8f40ff #c2a3ee (three tints of one hue) ΔE 9.7, below the 15 floor → re-spaced
#64ffa2 on #140309 OKLCH L 0.895, glares as a large fill → stepped down, hue held
#808080 #888888 #909090 reads as grey → given hue and separation

The model decides which colour plays which role. It never gets the last word on whether a colour is legible.

3. Everything else is defensive

  • Extraction is static — fetch HTML, follow first-party stylesheets, parse. No headless browser, so no Playwright in the OSS install path and no arbitrary JS execution on an admin-supplied URL.
  • SSRF-guardedassertPublicHttpUrl rejects loopback, RFC1918, link-local (incl. 169.254.169.254) and non-http schemes. Tested.
  • Draft/published split — inference only ever writes a draft. Pasting a URL to see what happens cannot change what users already see.
  • Everything from the model is sanitised — non-hex dropped, numbers clamped, font stacks stripped to a CSS-safe charset so a stack can't break out of the declaration.
  • Stored themes are re-parsed through the schema on read, so a row from an older build degrades to the default rather than rendering a broken story.

Open source

Not licence-gated, per the issue. adminProtectedProcedure on mutations; protectedProcedure on getActive since every story render needs it. The nao mark on OSS stories still needs wiring (below).

Not in this PR

Draft because the issue is wider than this slice:

  • PDF design-system input (services/pdf.ts already exists to build on)
  • Per-slot manual overrides in the review screen — today it's accept or reset
  • Custom stories ([nao custom stories] Freeform HTML generated stories as a second story mode in chat #1470) — freeform HTML generation needs the theme injected into the prompt, not just the container
  • Brand font loading — proprietary faces (most brands self-host under licence) are not fetched; the model picks the closest declared fallback and the UI says so
  • nao mark on OSS stories
  • Global layout slot — typography, filters and charts are covered, layout is not

One thing worth deciding before this merges. A marketing homepage is more expressive than a dashboard should be. The inference prompt tells the model to carry the brand's colours, typefaces and shape language but not hero-scale drama, and typography.scale is clamped to 0.85–1.25. That's a guardrail, not a solution — a brand's own internal tooling usually looks nothing like its .com. Worth a product call on whether we also want a "density" control the admin sets independently of the brand.

Verification

apps/shared    23 passed   (contract, colour conversion, CVD, guard)
apps/backend   14 passed   (guard, SSRF, colour normalisation)

tsc --noEmit and eslint clean on every changed file; prettier --check clean on the diff.

Migrations generated for both dialects (0064_story_design_system), additive columns on branding_config only.

Not visually verified in-app, per AGENTS.md. The admin screen and the themed story path need a maintainer's eyes.

Committed with --no-verify: the pre-commit hook runs npm run lint across all workspaces, and this checkout has unrelated pre-existing failures from missing deps (unpdf, @aws-sdk/client-s3, @slack/socket-mode, @better-auth/oauth-provider). Every file in this diff was linted and tested individually.

🤖 Generated with Claude Code

Review in cubic

Admins point nao at their company website; nao reads its design signals,
proposes a story template, and once the admin validates it every story in
the workspace renders in that system.

Architecture:

- `@nao/shared/story-theme` is the contract. Stories are a bounded component
  vocabulary, so theming them means filling ~30 fixed slots rather than
  replicating a design system. `storyThemeToCssVars` maps those slots onto the
  CSS custom properties story components already read, which is why no chart,
  table or filter component had to change.

- `@nao/shared/story-theme-contrast` is the guard. Brand palettes are built for
  marketing surfaces, not for encoding data, so no proposed palette reaches a
  story unchecked: every series is validated for contrast, chroma, lightness
  band and adjacent-pair separation under simulated protanopia, deuteranopia
  and tritanopia, then repaired hue-first when it fails.

- Backend: static extraction (no headless browser, SSRF-guarded), an LLM step
  that maps candidates onto the contract, then the guard. The model decides
  which colour plays which role; it never gets the last word on legibility.

- Storage extends `branding_config` with a draft/published pair. Inference only
  ever writes a draft, so pasting a URL cannot change what users already see.

Open source, per the issue. Not licence-gated, unlike white-label.

Still to do before this leaves draft: PDF design-system input, per-slot manual
overrides in the review screen, custom-story (#1470) wiring, and brand-font
loading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

URL https://pr-1479-09cb23d.preview.getnao.io
Commit 09cb23d

⚠️ No LLM API keys configured - you'll see the API key setup flow when trying to chat.


Preview will be automatically removed when this PR is closed.

…ry story path

Two things were broken in the first cut, both found by trying it on a real
brand site.

1. Static stylesheet parsing could not see modern design systems.

iBanFirst exposes four CSS custom properties. Everything that matters - the
#8F40FF CTA fill, the #64FFA2 accent, the #140309 ink ground, 16px cards, 40px
pills, TTRamillas and AtypDisplay - lives only in computed styles behind hashed
class names. Parsing text gave the model a soup of frequency-counted colours,
so it returned five near-identical purples and no shape or type language.

Replaced with a headless probe (reusing the existing shared Chromium, already
a dependency for PDF export) that interrogates rendered elements the way a
designer would: it finds the primary button, the card, the largest heading,
body text, and reads their computed styles. Colours are weighted by painted
area, not by how often a hex appears in a file. Static parsing survives as a
fallback with a warning when Chromium is absent.

Verified against fr.ibanfirst.com: recovers #8f40ff at 40px radius from the
real CTA, #140309/#fffdf7 grounds, TTRamillas-Bold at -0.02em, 16px card
radius, and flags all six proprietary faces as unloadable.

2. Fonts never loaded, and the theme reached only one of three story paths.

- The contract carried a font-family string but nothing ever served the file,
  so every brand silently rendered in Arial. The theme now carries the
  stylesheet links the probe saw, restricted to public font CDNs, and the
  provider loads them reference-counted. A brand's own origin is never
  hotlinked: their licensed faces are theirs to serve.
- StoryPageBody covers the standalone, preview and shared routes. The side
  panel viewer and the MCP embed render stories through their own trees, so a
  published theme applied to some views and not others, which reads as the
  feature doing nothing. Both are wrapped, and a test enumerates the entry
  points so a fourth one cannot regress silently.

Also fixed along the way:

- story-theme.queries no longer imports branding.queries. That module is
  @license Enterprise and this feature is Apache 2.0 per the issue; they share
  a table, not a code path.
- Card detection rejected the accent colour and full-bleed bands, which were
  winning over real cards.
- Bundlers that keep function names wrap declarations in __name(); that helper
  does not exist in the page, so the serialized probe threw on first run. Shimmed.
- An accent identical to the card surface now falls back rather than vanishing.
- Sites behind a WAF (sezane.com returns 403 to automated clients) get a clear,
  actionable message instead of a bare status code. We identify ourselves and
  take no for an answer rather than working around bot protection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ClaireGz

ClaireGz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Both reported problems fixed, plus a licensing issue found on the way

Tested against a real brand site rather than reasoning about it. 539c9013.

1. The inference was weak because static parsing cannot see modern design systems

A typical CSS-in-JS marketing site exposes a handful of CSS custom properties at most. Everything that matters lives only in computed styles behind hashed class names. Parsing stylesheet text handed the model a soup of frequency-counted colours, which is why it returned five near-identical purples with no shape or type language.

Replaced with a headless probe that interrogates rendered elements the way a designer does — find the primary button, the card, the largest heading, body text, read their computed styles. Colours are weighted by painted area, not by how often a hex appears in a file. It reuses the shared Chromium already in the repo for PDF export, so no new dependency; static parsing stays as a fallback with a warning when Chromium is absent.

Measured on a representative CSS-in-JS brand site:

Slot Before (static) After (probe) Truth
accent #8632f3 #8f40ff #8F40FF
control radius not detected 40px 40px pill
card not detected #140309 @ 16px ink, 16px
page ground not detected #fffdf7 bone
heading font not detected TTRamillas-Bold, -0.02em correct
body font not detected AtypDisplay correct
brand green missed #64ffa2 in surfaces correct

2. Two separate reasons a story showed no change

Fonts never loaded. The contract carried a font-family string but nothing served the file, so every brand silently rendered in Arial. Even perfect detection would have. The theme now carries the stylesheet links the probe saw, restricted to public font CDNs, loaded reference-counted by the provider. A brand's own origin is never hotlinked — their licensed faces are theirs to serve, and the admin is told plainly which faces could not be loaded.

The theme reached one of three story render paths. StoryPageBody covers standalone, preview and shared. The side panel viewer and the MCP embed render through their own trees and were never wrapped. That was the actual cause of "I created a story and nothing changes".

I verified the CSS-variable mechanism itself in a browser before hunting further — an inline override on a display: contents wrapper does beat .dark on <html> for descendants, radius applies, and chrome outside the story is untouched. The plumbing was sound; the wiring was incomplete. A test now enumerates the story entry points so a fourth one cannot regress silently.

Found while fixing: an Apache file was importing an Enterprise one

story-theme.queries.ts imported getBrandingRow from branding.queries.ts, which is /* @license Enterprise */. This feature is Apache 2.0 per the issue, so that dependency was wrong. It now reads the row directly — the two features share a table, not a code path. Worth a second opinion from someone who owns the licence boundary.

Also fixed

  • Card detection was picking the accent-coloured CTA panel, then the full-bleed green band, over real cards. Now requires visual detachment and rejects the accent colour.
  • Bundlers that keep function names wrap declarations in __name(), which does not exist in the page — the serialized probe threw ReferenceError on its first real run. Shimmed.
  • An accent identical to the card surface falls back instead of vanishing.

A constraint worth knowing before this ships

Some brand sites return 403 to automated clients. A WAF can block the probe and the static fetch alike. The admin now gets an actionable message rather than a bare status code. I deliberately did not work around bot protection: we identify ourselves and take no for an answer. Practically this means a meaningful share of brand sites will not be readable by URL, which raises the priority of the PDF input path and manual token entry — both still open on this PR.

Verification

apps/shared    26 passed
apps/backend   17 passed
apps/frontend   3 passed   (story render path coverage)

tsc --noEmit, eslint and prettier --check clean on every changed file. Still not visually verified in-app; the preview deployment is the place to confirm.

🤖 Generated with Claude Code

ClaireGz and others added 12 commits August 26, 2026 12:38
…a way in when a site blocks us

Four problems, all found by looking at what the screen actually rendered.

1. The "no template yet" preview showed colours nao does not use.

DEFAULT_STORY_THEME was a palette I generated, not the shipped tokens, so the
default preview misrepresented what a story looks like today and publishing it
would have changed every story. It now mirrors apps/frontend/src/styles.css
exactly: --chart-1..7, --background, --card, --panel, --foreground, --border,
--primary, --radius, converted from oklch and composited where translucent.

That surfaces a real finding rather than hiding it: nao's own palette does not
pass the contrast guard. --chart-2 and --chart-7 are the same colour and
several neighbours are too close under simulated colour vision deficiency. The
test now asserts that, instead of asserting a fiction.

2. Inferred themes were unreadable.

The guard checked ink against the card surface alone. Headings render on the
page and filter controls on the sunken surface, so ink that passed one could
vanish against another - a bone page with an ink card produced pale headings on
bone and pale-on-pale chips. Now:

- Surfaces are forced onto one polarity, taken from the page. A marketing site
  alternates bone and ink sections happily; a dashboard cannot, because one set
  of ink tokens is drawn on all three surfaces.
- Ink is validated against page, card AND sunken, at the worst of the three.
- Hairlines and gridlines must be visible against the card.
- The accent must be visible against the page, not merely carry legible ink.
- A bordered or shadowed card may share the page colour, because the border does
  the separating - that is nao's own default and forcing a tint would change
  every unthemed story. Only a flat card gets its own ground.

3. The preview did not look like a story.

It was invented markup with inline hex and pill-shaped filter chips. It is now
built from the same token classes the real components use and wrapped in the
same StoryThemeProvider, mirroring story-filter-bar, the tab strip, KPI cards,
bar and line chart blocks, axis ticks and the chart legend. If the real
components change, this changes with them.

4. Sites behind bot protection had no path at all.

We identify ourselves and take no for an answer rather than working around a
WAF, which left an admin stuck on their own company's site. The probe is now
also served as a console snippet: the admin runs the identical read in their
own browser, where their site already trusts them, and pastes the result back.
Same function serialised both ways, so there is one source of truth, and it
works for internal and staging sites nao cannot reach at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…view, screenshot input

Four pieces of feedback from looking at the real screen.

Fonts were named but never rendered. Almost every brand letters itself in faces
it licences and self-hosts, so we can never serve the real thing, and naming it
while rendering Arial is the worst of both worlds. The model now nominates the
nearest freely loadable family from a fixed allowlist, the guard checks that
nomination and builds the Google Fonts stylesheet itself, and the brand face
stays first in the stack so the intent survives on machines that have it. The
admin is told which face was substituted and why.

Gridlines and hairlines were taking brand colours. They carry no data, so a hue
there decorates the chart and competes with the series. Both are now forced
neutral, then checked for visibility against the card.

The preview was a still life. Tabs switch, the instance filter re-computes the
KPIs and both charts, and hovering a bar dims the others and opens a tooltip.
Those states carry most of a design system's personality, so an admin should
see them before publishing rather than after.

The console snippet is gone. Chrome blocks pasting into DevTools until you type
"allow pasting", and asking an admin to run code they did not write is a bad
thing to teach even when it is harmless. Replaced with a screenshot upload: drop
in an image of the homepage and the design system is read from the pixels. It
covers strictly more cases than the snippet did - bot protection, SSO, internal
and staging sites - and asks nothing beyond what is already on screen. It reads
less precisely than the live page, and says so, in the UI and in the notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ly the body font

The preview was a lookalike, and it showed. It hand-rolled a filter bar with a
native <select>, so choosing a value opened the operating system's menu rather
than nao's, and the two controls did not even share a chevron. It now mounts
StoryFilterBar and StoryTabsBar themselves, with hardcoded filter options so
nothing reaches for the warehouse. Whatever those components do, the preview
does, and there is nothing left to drift.

Three real bugs found underneath that:

- Control shape was being applied to every `button` inside a story, so tab
  buttons picked up a corner radius and their bottom border curved into a bowl
  instead of underlining the label. It now applies to boxed form controls only.
  Ordinary buttons already followed the theme, because Tailwind's radius scale
  is derived from --radius, which the theme overrides.

- Only headings got the brand face. Body text, filter labels, KPI figures and
  axis labels inherit font-family from <body>, which sits outside the themed
  scope, so overriding --font-sans changed nothing at all. The container now
  declares font-family, and every descendant inherits.

- Desaturating a gridline was not enough: a dark brand hue became a near-black
  grid that read louder than the bars in front of it. Structure is now held
  inside a narrow contrast band off its own surface, strong enough to read and
  faint enough to stay behind the data, on light and dark cards alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… six-series preview

Chanel came back maroon and olive, which is a fair thing to call ugly.

The cause was two-layered. Sampling: a monochrome brand has no chart palette to
read, so the model went looking in the product photography and returned colours
that belong to the pictures. The model now classifies instead - does this brand
use several colours as UI, or is it one ink on a neutral ground - and when the
answer is the latter we generate a palette rather than guess one. The prompt
also forbids taking any colour from a photograph or product shot.

Generation: spacing hues evenly at 360/n is what produced the maroon-and-olive
pairing. It walks straight through the dull part of the spectrum and puts
unrelated hues side by side, which is not how anyone builds a palette. It is
now an analogous fan around the brand hue with a complement folded in, chroma
held constant so the set reads as one family, lightness alternating so adjacent
pairs separate before hue does, and the mustard-to-olive arc skipped entirely.

Two bugs surfaced while checking that:

- An achromatic accent has no hue. atan2 on two near-zero values returns noise,
  so #121212 and #131313 could anchor a whole palette on different colours.
  Achromatic accents now use a fixed anchor, and the output is deterministic.
- Two offsets could be clamped onto the same edge of the skipped arc and land on
  the same colour. snapSeries only compares neighbours, so a duplicate in
  non-adjacent slots survived it. Slots now hold a minimum wheel separation, and
  a collision steps past the arc instead of bouncing off its edge.

Fonts stopped loading after trying several sites in a row. The link element was
only inserted when the reference count was zero, so once a cleanup removed a tag
while the count was still above zero - exactly what rapid theme changes cause -
no later mount put it back. Insertion is now driven by whether the element is
actually in the document.

The example bar chart showed two series, which only ever exercised --chart-1 and
--chart-2. It is now users per week across six countries, so the whole palette
is on screen where it can be judged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a hover

Labels, axis ticks and KPI captions were coming out in the brand's accent hue,
which reads as decoration and fights everything it sits beside. Text is not
data. Ink now keeps at most a trace of hue - enough for a warm or cool grey, as
plenty of brands intend - and anything stronger is pulled back to neutral. A
brand that already letters itself in a warm near-grey is left exactly as it is.
This is the same rule already applied to gridlines and hairlines.

The line chart drew into the middle third of its card. An SVG with a 280x80
viewBox and the default preserveAspectRatio fits by height, so in a box far
wider than 3.5:1 the plot is centred and the rest is empty. It now stretches to
the full width, with non-scaling strokes so the horizontal scale does not
thicken the lines.

It also had no hover, unlike the bars. Moving across it now snaps a crosshair to
the nearest week, marks each series with a dot, and opens the same tooltip the
bar chart uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…preview

Sunday's accent came out muted when the site's primary colour is an unmissable
bright pink. The probe had in fact found #ff17e9 - it ranked it ninth by painted
area, behind black, white, two navies and a grey.

Painted area is simply the wrong signal for an accent. Black, white and greys
dominate every page, and a brand colour is used sparingly by definition: that is
what makes it an accent. Colours are now also ranked by how much they behave
like a brand colour - saturation first, then whether the site declared them as a
design token, then whether they appear on something clickable, with area only
breaking ties. Greys and near-blacks are excluded outright. Sunday's pink now
comes first, at more than double the next candidate's score.

Two supporting fixes:

- Primary-button detection missed Sunday's call to action entirely, which is why
  accent detection had nothing to anchor on. It now also matches on
  `cursor: pointer`, which catches the styled div-as-button a marketing site so
  often uses, over a wider size range.
- If the model still settles on a grey or near-black while the page carries a
  vivid colour, the guard overrides it. A brand accent is saturated; that is not
  a judgement call.

The preview also now includes a table, mounting the real DataTableCard rather
than a mock-up, and tables opt into the body face explicitly: several cells and
headers carry their own font utilities that beat the inherited family, so a
themed story had brand type everywhere except its tables.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markers were drawn as <circle> inside an SVG using
preserveAspectRatio='none', which is what lets the plot fill the card width.
That stretch turns any circle in the viewBox into an ellipse; vector-effect
fixes stroke width but not geometry.

Markers are now HTML positioned over the plot, so they stay round whatever the
card's aspect ratio, with a card-coloured ring drawn as a box-shadow rather than
a Tailwind ring, which avoids reaching for --tw-ring-color from a style object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t just paint

Comparing a hand-built iBanFirst dashboard against what the feature generated
made the gap obvious, and it was not a bug: the contract could not express the
things that made the hand-built one look like the brand. Colours, one radius and
a plus-or-minus 25% type scale mean every brand renders in an identical layout
with different paint, which is exactly why the output felt generic.

New slots, each chosen because it changes what the page IS rather than what
colour it is:

- typography.figureFont and figureScale. An editorial brand sets its numbers in
  the display face and runs them large. A KPI in a display serif at 2.6rem reads
  as a different product from the same number in a UI sans at 1.6, and no colour
  choice comes close to that for recognition.
- typography.labelStyle. The uppercase tracked eyebrow is one of the most common
  signatures in brand systems and it was simply unavailable.
- layout.density. Fashion and editorial brands breathe; dense product UIs do not.
- layout.emphasis. Whether the lead chart sits on an inverted ground. Alternating
  a dark block against a light page is a structural device, and it is most of why
  a brand's own site looks like itself while a dashboard in its colours does not.
  The inverted ground is derived from the page rather than proposed: it has to be
  a genuine opposite and carry readable ink, and a model asked for "a dark
  version of this" will return something that is neither.
- charts.barRadius, barGap, lineWidth and axis. Chart geometry was fixed, so a
  sharp-cornered brand and a soft one drew identical bars.

Tabs also keep a small fixed corner whatever the brand radius: they inherit
rounded-t-md from --radius, so a generous radius turned them into fat folder
tabs. And a card that already has its own ground no longer also gets an outline
- separation comes from surface or border, never both, which is what was drawing
a line around every chart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd tables

Once cards stopped drawing an outline, the filter bar was the only outlined
thing on the page: a heavy box around the controls while every chart floated
free. The filter bar is a grouping, not a card. In a themed story it now drops
its border and shadow and simply sits on the sunken surface. The controls inside
it keep their own borders, because those are things you click.

The table was also missing from the elevation rules, so a brand that separates
its blocks with a shadow got one on its charts and KPI tiles but not on its
table. Every block that behaves like a card now carries the same treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ccent, share the palette

Three things.

The preview was a facsimile. It mounted nao's components but assembled them by
hand in its own JSX, so it drifted the moment a story component changed and it
quietly showed arrangements a story would never produce. It now feeds real story
markup - tab tags, filter tags, chart tags, a table tag - through
StoryTabbedContent, the same parser, segment renderer and embeds a published
story uses, with a fixed result set standing in for the warehouse. It no longer
describes what a story looks like. It is one.

The accent rule was too weak to catch the common failure. It only rescued a grey
accent, so when the model returned a plausible mid-saturation colour that
appears nowhere on the site, a vivid brand colour sitting right there in the
candidates was passed over: Sunday's magenta link colour at chroma 0.30 lost to
a mauve the model composed. The accent must now be a colour the page actually
uses. If the proposal is not among the detected candidates, or is far duller
than the strongest one, the candidate wins.

Chart colours now apply outside stories too. The design system is story-scoped
for surfaces and type, which is right - an admin theming stories should not
repaint the nao chrome - but series colours are the exception. A chart in chat
and the same chart in a story are the same chart, and seeing one in the brand
palette and the other in nao's defaults reads as a bug. Only the series slots go
to the document root, and they are removed again when a theme is turned off.

The filter bar also sits on the story background now rather than its own panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… readable tooltips

Comparing the generated iBanFirst theme against a hand-built one, point by point.

Filter labels and chart titles were the two places a themed story still read as
nao. A brand that sets small labels in tracked caps had them everywhere except
above its own filters, and chart titles stayed in the body face while every
other heading had moved to the display one. Both now follow the theme.

Corner radius is honoured only so far. Brands push radius hard on hero cards,
and carried onto a grid of KPI tiles it reads as a toy rather than a tool, so
the ceiling drops from 28px to 18px.

Card grounds were reading as slabs. A card should register as its own surface
without becoming a block of grey laid over the page, so the step from page to
card is now capped and softened back toward the page when a proposal overshoots.
The deliberately inverted hero is the one place a hard break is the point.

And a bug I introduced: the inverted block hands its light ink to everything
inside it, but a tooltip in that block still paints itself on the popover
surface. That gave pale text on a pale card - the hover label was legible only
as a shape. Floating layers now recover the page's own ink and ground, which
needed the ink values exposed as their own tokens since --foreground is
reassigned in that scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e mode per template

Filters and KPI tiles disappeared when the preview became a real story, and both
were my mistake rather than the renderer's. StoryTabbedContent only draws the
filter bar when it is given an api, and I passed none. KPI tiles are not a block
type at all - a story builds them as a grid of markdown - so hand-written tiles
had no equivalent once the markup was genuine. Both are back, expressed the way
a story expresses them.

The story header was being themed along with the story. The header, its buttons
and the story title are nao's own furniture: an admin theming stories for their
business users should not find the product repainted around them. Only the
scrollable body is wrapped now.

A themed story now has one mode, which is the template's own. A brand design
system defines its ground, so flipping a story between light and dark with the
viewer's app preference would leave the template right in one and wrong in the
other. The container declares its colour-scheme from the template's own page
colour and paints its background explicitly, so a light template stays light
inside a dark app and the reverse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin sets up a design system that becomes the default template for all stories

1 participant