feat(stories): infer a workspace design system from a website (#1463) - #1479
feat(stories): infer a workspace design system from a website (#1463)#1479ClaireGz wants to merge 14 commits into
Conversation
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>
🚀 Preview Deployment
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>
Both reported problems fixed, plus a licensing issue found on the wayTested against a real brand site rather than reasoning about it. 1. The inference was weak because static parsing cannot see modern design systemsA 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:
2. Two separate reasons a story showed no changeFonts 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. I verified the CSS-variable mechanism itself in a browser before hunting further — an inline override on a Found while fixing: an Apache file was importing an Enterprise one
Also fixed
A constraint worth knowing before this shipsSome 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
🤖 Generated with Claude Code |
…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>
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
1. The contract is the architecture
@nao/shared/story-themedefines 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).StoryThemeProvidersets 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-contrastis 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:
#712dd0#8f40ff#c2a3ee(three tints of one hue)#64ffa2on#140309#808080#888888#909090The model decides which colour plays which role. It never gets the last word on whether a colour is legible.
3. Everything else is defensive
assertPublicHttpUrlrejects loopback, RFC1918, link-local (incl.169.254.169.254) and non-http schemes. Tested.Open source
Not licence-gated, per the issue.
adminProtectedProcedureon mutations;protectedProcedureongetActivesince 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:
services/pdf.tsalready exists to build on)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.scaleis 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
tsc --noEmitandeslintclean on every changed file;prettier --checkclean on the diff.Migrations generated for both dialects (
0064_story_design_system), additive columns onbranding_configonly.Not visually verified in-app, per
AGENTS.md. The admin screen and the themed story path need a maintainer's eyes.🤖 Generated with Claude Code