diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b558b..2db3c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [0.15.0] - 2026-07-27 + +### Breaking + +- **The `warm` tone and the `warm` / `warmLight` tokens are removed.** `--nb-color-warm` was a second accent channel that no host injects — the NimbleBrain runtime's projection covers ten colour vars and this was never among them — so every app rendering `` painted the SDK's own fallback, `#d4620a`, a hex from a retired brand generation. A library cannot ship a channel it has no unbranded value for: the neutral answer to "a second accent" is that there isn't one. + + **Migration.** `tone="warm"` → `tone="accent"` where the badge is informational, `tone="warning"` where it is a caution. `tokens.warm` / `tokens.warmLight` → `tokens.accent` / `tokens.infoLight`, or the `warning` pair. Apps pinned to `^0.11.0`–`^0.14.0` are unaffected until they bump, since caret on `0.x` will not cross a minor. + +### Fixed + +- **Headings fall back to the body stack, not a serif.** `tokens.fontHeading` fell back to `Georgia, 'Times New Roman', serif`, so any host that injects no `--nb-font-heading` rendered headings in a display serif — the same brand-by-default problem the removal of `@nimblebrain/synapse/ui/fonts` addressed in 0.13.0, one token over. The fallback is now the sans stack: one family, hierarchy from weight and size. +- **The vendored Python IIFE tracks the build.** `python/nimblebrain_synapse/_assets/synapse-ui.iife.js` is a second shipping path, and it carried the old values independently of `dist/`. + +### Added + +- **A neutrality guard on the colour defaults.** Every hex in `DEFAULT_THEME_VARS` *and* in the `tokens` `var()` fallbacks must appear in a declared sanctioned set — neutral ladder, a generic blue, and the generic semantic hues. Both maps, because both are the unbranded-default claim and the brand orange sat in both: one is the block the SDK injects, the other is what each component resolves against when a host declares nothing. Typography got a guard in 0.13.0; colour had none, which is how that orange survived two releases under docblocks claiming the values were unbranded. An allowlist rather than a denylist: a denylist only catches the brand values someone thought to name, which is how the last one got in. ## [0.14.1] - 2026-07-27 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6c26d9c..f40dd25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ npm run ci # lint → typecheck → build → test To cut a release: 1. Bump `version` in `package.json` and add a CHANGELOG entry on the release branch. + **A `### Breaking` entry means the minor moves.** Nothing enforces this: the workflow + checks the tag matches `package.json` and that a `## []` heading exists, and + neither tells a minor from a patch. Consumers pin caret on `0.x`, which does not cross a + minor — so cutting a breaking change as a patch carries it to every pinned consumer + silently, which is the opposite of what the CHANGELOG's migration note promises. 2. Merge the PR. 3. Tag the merge commit on `main` and push the tag: ```bash diff --git a/README.md b/README.md index 19f5d5c..cbc59e5 100644 --- a/README.md +++ b/README.md @@ -121,8 +121,8 @@ tokens, and the SDK loads them into the app document. Three things worth knowing: - **Sending no fonts is a supported configuration, not a degraded one.** Omit - `fontFaces` and the app renders in the web-safe fallbacks (`system-ui`, - `ui-monospace`, `Georgia`). + `fontFaces` and the app renders in the web-safe fallbacks (`system-ui` for + both body and headings, `ui-monospace` for code). - **Give every `--font-*` token value a web-safe tail.** A bare family name with no matching face falls through to the browser default, not to your intended stack. `"'Your Sans', system-ui, sans-serif"`, never `"'Your Sans'"`. diff --git a/gallery/src/App.tsx b/gallery/src/App.tsx index 610a90f..742089d 100644 --- a/gallery/src/App.tsx +++ b/gallery/src/App.tsx @@ -94,7 +94,6 @@ const BADGE_TONES: BadgeTone[] = [ "warning", "danger", "processing", - "warm", ]; const STATUSES: Status[] = ["working", "completed", "failed", "idle"]; @@ -241,7 +240,6 @@ export function App() { - @@ -317,7 +315,7 @@ export function App() { Inline with - gap + gap ← Spacer pushes → diff --git a/gallery/src/demo-theme.ts b/gallery/src/demo-theme.ts index 9979dd0..084f1ae 100644 --- a/gallery/src/demo-theme.ts +++ b/gallery/src/demo-theme.ts @@ -86,8 +86,6 @@ const DEFAULT = preset({ "--nb-color-danger": "#dc2626", "--nb-color-success": "#16a34a", "--nb-color-warning": "#d97706", - "--nb-color-warm": "#ea580c", - "--nb-color-warm-light": "#fff7ed", "--nb-color-processing": "#7c3aed", "--nb-color-processing-light": "#f5f3ff", "--nb-color-info-light": "#eff6ff", @@ -111,8 +109,6 @@ const DEFAULT = preset({ "--nb-color-danger": "#f87171", "--nb-color-success": "#4ade80", "--nb-color-warning": "#fbbf24", - "--nb-color-warm": "#fb923c", - "--nb-color-warm-light": "#2a1408", "--nb-color-processing": "#a78bfa", "--nb-color-processing-light": "#1e1b2e", "--nb-color-info-light": "#0c1a33", @@ -153,8 +149,6 @@ const ALMANAC = preset({ "--nb-color-danger": "#a72a25", "--nb-color-success": "#5a6e30", "--nb-color-warning": "#9a6312", - "--nb-color-warm": "#99551f", - "--nb-color-warm-light": "#f3e9d6", "--nb-color-processing": "#6a4a6f", "--nb-color-processing-light": "#efe6ef", "--nb-color-info-light": "#ece3d2", @@ -178,8 +172,6 @@ const ALMANAC = preset({ "--nb-color-danger": "#e08a84", "--nb-color-success": "#a3b072", "--nb-color-warning": "#d3a85e", - "--nb-color-warm": "#cf9a5e", - "--nb-color-warm-light": "#2c2114", "--nb-color-processing": "#b596b8", "--nb-color-processing-light": "#241a26", "--nb-color-info-light": "#221a10", @@ -220,8 +212,6 @@ const FLUX = preset({ "--nb-color-danger": "#c81e3a", "--nb-color-success": "#0a8f4f", "--nb-color-warning": "#b3690a", - "--nb-color-warm": "#b5530a", - "--nb-color-warm-light": "#fdf0e6", "--nb-color-processing": "#1d6fb8", "--nb-color-processing-light": "#e8f1fb", "--nb-color-info-light": "#e9f7ef", @@ -245,8 +235,6 @@ const FLUX = preset({ "--nb-color-danger": "#ff4d6d", "--nb-color-success": "#39ff97", "--nb-color-warning": "#ffd23f", - "--nb-color-warm": "#ff9f45", - "--nb-color-warm-light": "#241405", "--nb-color-processing": "#4cc2ff", "--nb-color-processing-light": "#07212e", "--nb-color-info-light": "#06231a", @@ -287,8 +275,6 @@ const BLOOM = preset({ "--nb-color-danger": "#e26d7a", "--nb-color-success": "#4fae8f", "--nb-color-warning": "#e0a64e", - "--nb-color-warm": "#e08a5e", - "--nb-color-warm-light": "#fdf0e9", "--nb-color-processing": "#9b7bff", "--nb-color-processing-light": "#f1ecff", "--nb-color-info-light": "#eef0ff", @@ -312,8 +298,6 @@ const BLOOM = preset({ "--nb-color-danger": "#f0909b", "--nb-color-success": "#6fccaf", "--nb-color-warning": "#f0c074", - "--nb-color-warm": "#f0a37e", - "--nb-color-warm-light": "#2c1c14", "--nb-color-processing": "#b9a3ff", "--nb-color-processing-light": "#241d38", "--nb-color-info-light": "#1a1d33", diff --git a/package-lock.json b/package-lock.json index 8afb71d..e528e8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nimblebrain/synapse", - "version": "0.14.1", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nimblebrain/synapse", - "version": "0.14.1", + "version": "0.15.0", "license": "MIT", "bin": { "synapse": "dist/codegen/cli.js" diff --git a/package.json b/package.json index fe18d74..ea76851 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nimblebrain/synapse", - "version": "0.14.1", + "version": "0.15.0", "description": "Agent-aware app SDK for the MCP ext-apps protocol", "type": "module", "exports": { diff --git a/python/nimblebrain_synapse/__init__.py b/python/nimblebrain_synapse/__init__.py index 6692c25..7bb70b3 100644 --- a/python/nimblebrain_synapse/__init__.py +++ b/python/nimblebrain_synapse/__init__.py @@ -31,4 +31,4 @@ # independently of the JS one (different cadence, different consumers); the two # meet only on the wire protocol. CI keeps this equal to the sibling package.json # version (ci.yml build job), so the pin can't silently go stale. -__client_version__ = "0.14.1" +__client_version__ = "0.15.0" diff --git a/python/nimblebrain_synapse/_assets/synapse-ui.iife.js b/python/nimblebrain_synapse/_assets/synapse-ui.iife.js index 83393ee..c2b002a 100644 --- a/python/nimblebrain_synapse/_assets/synapse-ui.iife.js +++ b/python/nimblebrain_synapse/_assets/synapse-ui.iife.js @@ -1,6 +1,6 @@ -(function(){'use strict';var be=new Set(["auto","block","swap","fallback","optional"]),ke={"--color-background-primary":"#ffffff","--color-background-secondary":"#fafafa","--color-background-tertiary":"#f3f4f6","--color-text-primary":"#111827","--color-text-secondary":"#6b7280","--color-text-tertiary":"#9ca3af","--color-text-accent":"#2563eb","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#e5e7eb","--color-border-secondary":"#d1d5db","--color-ring-primary":"#2563eb","--nb-color-danger":"#dc2626","--nb-color-success":"#059669","--nb-color-warning":"#f59e0b","--nb-color-warm":"#d4620a","--nb-color-warm-light":"#fef5ee","--nb-color-processing":"#7c3aed","--nb-color-processing-light":"#f3eeff","--nb-color-info-light":"#eef4ff"},Pe={"--color-background-primary":"#18181b","--color-background-secondary":"#27272a","--color-background-tertiary":"#2f2f34","--color-text-primary":"#fafafa","--color-text-secondary":"#a1a1aa","--color-text-tertiary":"#71717a","--color-text-accent":"#818cf8","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#3f3f46","--color-border-secondary":"#52525b","--color-ring-primary":"#818cf8","--nb-color-danger":"#f87171","--nb-color-success":"#34d399","--nb-color-warning":"#fbbf24","--nb-color-warm":"#fb923c","--nb-color-warm-light":"#3a2a1e","--nb-color-processing":"#a78bfa","--nb-color-processing-light":"#2a2440","--nb-color-info-light":"#1e2a44"},Ee={light:ke,dark:Pe},Ie="synapse-defaults",G="synapse-theme-defaults",W=new Set;function we(){return typeof document<"u"&&typeof document.getElementById=="function"&&typeof document.createElement=="function"&&typeof document.head?.prepend=="function"}function ve(e){if(!we())return;let o=Object.entries(Ee[e]).map(([a,p])=>` ${a}: ${p};`).join(` +(function(){'use strict';var be=new Set(["auto","block","swap","fallback","optional"]),ke={"--color-background-primary":"#ffffff","--color-background-secondary":"#fafafa","--color-background-tertiary":"#f3f4f6","--color-text-primary":"#111827","--color-text-secondary":"#6b7280","--color-text-tertiary":"#9ca3af","--color-text-accent":"#2563eb","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#e5e7eb","--color-border-secondary":"#d1d5db","--color-ring-primary":"#2563eb","--nb-color-danger":"#dc2626","--nb-color-success":"#059669","--nb-color-warning":"#f59e0b","--nb-color-processing":"#7c3aed","--nb-color-processing-light":"#f3eeff","--nb-color-info-light":"#eef4ff"},Pe={"--color-background-primary":"#18181b","--color-background-secondary":"#27272a","--color-background-tertiary":"#2f2f34","--color-text-primary":"#fafafa","--color-text-secondary":"#a1a1aa","--color-text-tertiary":"#71717a","--color-text-accent":"#818cf8","--nb-color-accent-foreground":"#ffffff","--color-border-primary":"#3f3f46","--color-border-secondary":"#52525b","--color-ring-primary":"#818cf8","--nb-color-danger":"#f87171","--nb-color-success":"#34d399","--nb-color-warning":"#fbbf24","--nb-color-processing":"#a78bfa","--nb-color-processing-light":"#2a2440","--nb-color-info-light":"#1e2a44"},Ee={light:ke,dark:Pe},Ie="synapse-defaults",G="synapse-theme-defaults",W=new Set;function ve(){return typeof document<"u"&&typeof document.getElementById=="function"&&typeof document.createElement=="function"&&typeof document.head?.prepend=="function"}function Ce(e){if(!ve())return;let o=Object.entries(Ee[e]).map(([a,p])=>` ${a}: ${p};`).join(` `),n=`@layer ${Ie} { :root { ${o} } -}`,s=document.getElementById(G);if(s){s.textContent!==n&&(s.textContent=n);return}let i=document.createElement("style");i.id=G,i.textContent=n,document.head.prepend(i);}function Ce(e,o){if(typeof document>"u")return;ve(e);let n={};if(o&&typeof o=="object")for(let[i,a]of Object.entries(o))typeof i=="string"&&typeof a=="string"&&(n[i]=a);let s=document.documentElement.style;if(typeof s.removeProperty=="function")for(let i of W)i in n||s.removeProperty(i);for(let[i,a]of Object.entries(n))s.setProperty(i,a);W=new Set(Object.keys(n));}var F=new Set,Y="";function A(e){return Array.isArray(e)?JSON.stringify(e.map(o=>[o.family,o.src,o.weight,o.style,o.display])):""}function M(e){if(!Array.isArray(e))return;let o=e.filter(Oe);if(!(e.length>0&&o.length===0))return o}function Ae(e){let o=M(e);if(o===void 0||typeof document>"u"||!document.fonts||typeof FontFace>"u")return;let n=A(o);if(n!==Y){Y=n;for(let s of F)try{document.fonts.delete(s);}catch{}F.clear();for(let s of o)try{let i=new FontFace(s.family,s.src,{weight:s.weight,style:s.style,display:s.display&&be.has(s.display)?s.display:"swap"});document.fonts.add(i),F.add(i);}catch{}}}function Oe(e){if(!e||typeof e!="object")return false;let o=e;return typeof o.family=="string"&&typeof o.src=="string"}function V(e,o,n){Ce(e,o),Ae(n);}function R(e){typeof document<"u"&&document.documentElement.setAttribute("data-theme",e.mode),V(e.mode,e.tokens,e.fontFaces);}function O(e){try{if(e?.matchMedia?.("(prefers-color-scheme: dark)").matches)return "dark"}catch{}return "light"}function h(e,o){return e==="light"||e==="dark"?e:o}var T=class extends Error{constructor(o,n){super(`"${o}" is not supported by the "${n}" host`),this.name="HostUnsupportedError";}},_="synapse-ui-data",Z="ui-lifecycle-iframe-ready",$="ui-lifecycle-iframe-render-data",B="ui-size-change",q="link",Q="prompt",U="openai:set_globals",X="2026-01-26",J="ui/initialize",ee="ui/notifications/initialized",te="ui/notifications/tool-result",ne="ui/notifications/host-context-changed",oe="ui/notifications/size-changed",re="ui/open-link",se="ui/message",ie="ui/resource-teardown",ae="tools/call";function ce(e,o){let n=()=>e.openai,s=n()?.toolOutput??null,i={mode:h(n()?.theme,"light"),tokens:{}},a=new Set,p=new Set,f=false,m=c=>{if(f)return;let l=c.detail?.globals;if(l){if("toolOutput"in l&&l.toolOutput!=null){s=l.toolOutput;for(let u of a)u(s);}if("theme"in l&&l.theme!=null){let u=h(l.theme,i.mode);if(u!==i.mode){i={mode:u,tokens:{}};for(let b of p)b(i);}}}};return {host:"chatgpt",getData:()=>s,onData(c){return a.add(c),()=>a.delete(c)},getTheme:()=>i,onTheme(c){return p.add(c),()=>p.delete(c)},async callTool(c,l){let u=n()?.callTool;if(!u)throw new T("callTool","chatgpt");return await u(c,l??{})},sendPrompt(c){let l=n();(l?.sendFollowUpMessage??l?.sendFollowupMessage)?.({prompt:c});},openLink(c){let l=n()?.openExternal;l?l({href:c}):e.open(c,"_blank","noopener,noreferrer");},resize(){},capabilities(){let c=n();return {pull:typeof c?.callTool=="function",sendPrompt:typeof c?.sendFollowUpMessage=="function"||typeof c?.sendFollowupMessage=="function",openLink:true}},start(){e.addEventListener(U,m,{passive:true}),s=n()?.toolOutput??s;},destroy(){f||(f=true,e.removeEventListener(U,m),a.clear(),p.clear());}}}function S(e,o){if(!e)return null;let s=e.getElementById(o)?.textContent;if(!s)return null;try{return JSON.parse(s)??null}catch{return null}}function L(e){if(e==null||typeof e!="object")return null;let o=e,n=o.renderData,s=n!=null&&typeof n=="object"?n:o;return s.toolOutput!=null?s.toolOutput:s.structuredContent!=null?s.structuredContent:s}function le(e,o){let n=o.dataElementId??_,s=null,i={mode:O(e),tokens:{}},a=new Set,p=false,f=null,m=c=>{if(p)return;let l=h(c.matches?"dark":"light",i.mode);if(l!==i.mode){i={mode:l,tokens:{}};for(let u of a)u(i);}};return {host:"generic",getData:()=>s,onData(){return ()=>{}},getTheme:()=>i,onTheme(c){return a.add(c),()=>a.delete(c)},async callTool(c){throw new T("callTool","generic")},sendPrompt(){},openLink(c){e.open(c,"_blank","noopener,noreferrer");},resize(){},capabilities(){return {pull:false,sendPrompt:false,openLink:true}},start(){s=S(e.document,n);try{f=e.matchMedia?.("(prefers-color-scheme: dark)")??null,f?.addEventListener?.("change",m);}catch{f=null;}},destroy(){p||(p=true,f?.removeEventListener?.("change",m),f=null,a.clear());}}}var _e="synapse/fontFaces";function Se(e){return M(e?.[_e])}function de(e,o){return Se(o)??e}var xe=3e4;function pe(e,o){let n=o.dataElementId??_,s=o.autoResize!==false,i=null,a={mode:O(e),tokens:{}},p=new Set,f=new Set,m=false,c=false,l=1,u=new Map,b=-1,w=null,k=null,H=()=>e.parent??e;function P(t){H().postMessage(t,"*");}function x(t){c||P(t);}function N(t,r){P({jsonrpc:"2.0",method:t,params:r??{}});}function v(t,r){let d=l++;return new Promise((y,g)=>{let I=setTimeout(()=>{u.delete(d),g(new Error(`"${t}" timed out`));},xe);u.set(d,{resolve:y,reject:g,timer:I}),P({jsonrpc:"2.0",id:d,method:t,params:r??{}});})}function z(t){if(t!=null){i=t;for(let r of p)r(t);}}function D(t){if(!t||typeof t!="object")return;let{mode:r,tokens:d,fontFaces:y}=a,g=false;if(t.theme!=null){let C=h(t.theme,r);C!==r&&(r=C,g=true);}let I=t.styles;I?.variables&&typeof I.variables=="object"&&(d={...d,...I.variables},g=true);let K=de(y,t);if(A(K)!==A(y)&&(y=K,g=true),g){a={mode:r,tokens:d,...y?{fontFaces:y}:{}};for(let C of f)C(a);}}function E(t){if(m)return;let r=typeof t=="number"?t:Math.ceil(e.document.body.scrollHeight);r!==b&&(b=r,N(oe,{height:r}),x({type:B,payload:{height:r}}));}function me(t){let r=Number(t.id),d=u.get(r);if(d)if(u.delete(r),clearTimeout(d.timer),t.error!=null){let y=t.error;d.reject(new Error(y.message??"request failed"));}else d.resolve(t.result);}function ye(t,r){if(t===te){let d=r.structuredContent;z(d??L(r));}else t===ne&&D(r);}function ge(t){t.method===ie&&P({jsonrpc:"2.0",id:t.id,result:{}});}function he(t){if(t.type===$||t.type==="renderData"){let{theme:r,...d}=t.payload??{};r!=null&&D({theme:r}),Object.keys(d).length>0&&z(L(d));}}let j=t=>{if(m||t.source&&t.source!==H())return;let r=t.data;if(!(!r||typeof r!="object")){if(r.jsonrpc!=="2.0"){he(r);return}r.id!=null&&("result"in r||"error"in r)?me(r):typeof r.method=="string"&&(r.id!=null?ge(r):ye(r.method,r.params??{}));}};function Te(){k=()=>E(),e.addEventListener("resize",k),typeof e.ResizeObserver<"u"&&(w=new e.ResizeObserver(()=>E()),w.observe(e.document.body));}return {host:"claude",getData:()=>i,onData(t){return p.add(t),()=>p.delete(t)},getTheme:()=>a,onTheme(t){return f.add(t),()=>f.delete(t)},async callTool(t,r){return await v(ae,{name:t,arguments:r??{}})},sendPrompt(t){v(se,{role:"user",content:[{type:"text",text:t}]}).catch(()=>{}),x({type:Q,payload:{prompt:t}});},openLink(t){v(re,{url:t}).catch(()=>{}),x({type:q,payload:{url:t}});},resize(t){E(t);},capabilities(){return {pull:true,sendPrompt:true,openLink:true}},start(){e.addEventListener("message",j),i=S(e.document,n),s&&Te(),P({type:Z}),v(J,{appInfo:{name:o.name??"synapse-ui",version:o.version??"0.0.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:X}).then(t=>{m||(c=true,D(t?.hostContext),N(ee,{}),b=-1,E());}).catch(()=>{}),E();},destroy(){if(!m){m=true,e.removeEventListener("message",j),k&&e.removeEventListener("resize",k),k=null,w?.disconnect(),w=null;for(let t of u.values())clearTimeout(t.timer),t.reject(new Error("adapter destroyed"));u.clear(),p.clear(),f.clear();}}}}function De(e){if(e.openai!=null)return "chatgpt";try{if(e.parent!=null&&e.parent!==e)return "claude"}catch{return "claude"}return "generic"}function Fe(e,o,n){switch(e){case "chatgpt":return ce(o);case "claude":case "nimblebrain":return pe(o,n);default:return le(o,n)}}function ue(e,o){let n=o.host??De(e);return Fe(n,e,o)}function fe(e={}){let o=e.window??globalThis,n=ue(o,e);R(n.getTheme());let s=n.onTheme(R);n.start();let i=false;return {data:()=>n.getData(),onData:a=>n.onData(a),theme:()=>n.getTheme(),onTheme:a=>n.onTheme(a),callTool:(a,p)=>n.callTool(a,p),sendPrompt:a=>n.sendPrompt(a),openLink:a=>n.openLink(a),resize:a=>n.resize(a),capabilities:()=>n.capabilities(),host:()=>n.host,destroy(){i||(i=true,s(),n.destroy());}}}globalThis.SynapseUI={connect:fe};})(); \ No newline at end of file +}`,s=document.getElementById(G);if(s){s.textContent!==n&&(s.textContent=n);return}let i=document.createElement("style");i.id=G,i.textContent=n,document.head.prepend(i);}function we(e,o){if(typeof document>"u")return;Ce(e);let n={};if(o&&typeof o=="object")for(let[i,a]of Object.entries(o))typeof i=="string"&&typeof a=="string"&&(n[i]=a);let s=document.documentElement.style;if(typeof s.removeProperty=="function")for(let i of W)i in n||s.removeProperty(i);for(let[i,a]of Object.entries(n))s.setProperty(i,a);W=new Set(Object.keys(n));}var F=new Set,Y="";function A(e){return Array.isArray(e)?JSON.stringify(e.map(o=>[o.family,o.src,o.weight,o.style,o.display])):""}function M(e){if(!Array.isArray(e))return;let o=e.filter(Oe);if(!(e.length>0&&o.length===0))return o}function Ae(e){let o=M(e);if(o===void 0||typeof document>"u"||!document.fonts||typeof FontFace>"u")return;let n=A(o);if(n!==Y){Y=n;for(let s of F)try{document.fonts.delete(s);}catch{}F.clear();for(let s of o)try{let i=new FontFace(s.family,s.src,{weight:s.weight,style:s.style,display:s.display&&be.has(s.display)?s.display:"swap"});document.fonts.add(i),F.add(i);}catch{}}}function Oe(e){if(!e||typeof e!="object")return false;let o=e;return typeof o.family=="string"&&typeof o.src=="string"}function V(e,o,n){we(e,o),Ae(n);}function R(e){typeof document<"u"&&document.documentElement.setAttribute("data-theme",e.mode),V(e.mode,e.tokens,e.fontFaces);}function O(e){try{if(e?.matchMedia?.("(prefers-color-scheme: dark)").matches)return "dark"}catch{}return "light"}function h(e,o){return e==="light"||e==="dark"?e:o}var T=class extends Error{constructor(o,n){super(`"${o}" is not supported by the "${n}" host`),this.name="HostUnsupportedError";}},_="synapse-ui-data",Z="ui-lifecycle-iframe-ready",$="ui-lifecycle-iframe-render-data",B="ui-size-change",q="link",Q="prompt",U="openai:set_globals",X="2026-01-26",J="ui/initialize",ee="ui/notifications/initialized",te="ui/notifications/tool-result",ne="ui/notifications/host-context-changed",oe="ui/notifications/size-changed",re="ui/open-link",se="ui/message",ie="ui/resource-teardown",ae="tools/call";function ce(e,o){let n=()=>e.openai,s=n()?.toolOutput??null,i={mode:h(n()?.theme,"light"),tokens:{}},a=new Set,p=new Set,f=false,m=c=>{if(f)return;let l=c.detail?.globals;if(l){if("toolOutput"in l&&l.toolOutput!=null){s=l.toolOutput;for(let u of a)u(s);}if("theme"in l&&l.theme!=null){let u=h(l.theme,i.mode);if(u!==i.mode){i={mode:u,tokens:{}};for(let b of p)b(i);}}}};return {host:"chatgpt",getData:()=>s,onData(c){return a.add(c),()=>a.delete(c)},getTheme:()=>i,onTheme(c){return p.add(c),()=>p.delete(c)},async callTool(c,l){let u=n()?.callTool;if(!u)throw new T("callTool","chatgpt");return await u(c,l??{})},sendPrompt(c){let l=n();(l?.sendFollowUpMessage??l?.sendFollowupMessage)?.({prompt:c});},openLink(c){let l=n()?.openExternal;l?l({href:c}):e.open(c,"_blank","noopener,noreferrer");},resize(){},capabilities(){let c=n();return {pull:typeof c?.callTool=="function",sendPrompt:typeof c?.sendFollowUpMessage=="function"||typeof c?.sendFollowupMessage=="function",openLink:true}},start(){e.addEventListener(U,m,{passive:true}),s=n()?.toolOutput??s;},destroy(){f||(f=true,e.removeEventListener(U,m),a.clear(),p.clear());}}}function S(e,o){if(!e)return null;let s=e.getElementById(o)?.textContent;if(!s)return null;try{return JSON.parse(s)??null}catch{return null}}function L(e){if(e==null||typeof e!="object")return null;let o=e,n=o.renderData,s=n!=null&&typeof n=="object"?n:o;return s.toolOutput!=null?s.toolOutput:s.structuredContent!=null?s.structuredContent:s}function le(e,o){let n=o.dataElementId??_,s=null,i={mode:O(e),tokens:{}},a=new Set,p=false,f=null,m=c=>{if(p)return;let l=h(c.matches?"dark":"light",i.mode);if(l!==i.mode){i={mode:l,tokens:{}};for(let u of a)u(i);}};return {host:"generic",getData:()=>s,onData(){return ()=>{}},getTheme:()=>i,onTheme(c){return a.add(c),()=>a.delete(c)},async callTool(c){throw new T("callTool","generic")},sendPrompt(){},openLink(c){e.open(c,"_blank","noopener,noreferrer");},resize(){},capabilities(){return {pull:false,sendPrompt:false,openLink:true}},start(){s=S(e.document,n);try{f=e.matchMedia?.("(prefers-color-scheme: dark)")??null,f?.addEventListener?.("change",m);}catch{f=null;}},destroy(){p||(p=true,f?.removeEventListener?.("change",m),f=null,a.clear());}}}var _e="synapse/fontFaces";function Se(e){return M(e?.[_e])}function de(e,o){return Se(o)??e}var xe=3e4;function pe(e,o){let n=o.dataElementId??_,s=o.autoResize!==false,i=null,a={mode:O(e),tokens:{}},p=new Set,f=new Set,m=false,c=false,l=1,u=new Map,b=-1,v=null,k=null,H=()=>e.parent??e;function P(t){H().postMessage(t,"*");}function x(t){c||P(t);}function N(t,r){P({jsonrpc:"2.0",method:t,params:r??{}});}function C(t,r){let d=l++;return new Promise((y,g)=>{let I=setTimeout(()=>{u.delete(d),g(new Error(`"${t}" timed out`));},xe);u.set(d,{resolve:y,reject:g,timer:I}),P({jsonrpc:"2.0",id:d,method:t,params:r??{}});})}function z(t){if(t!=null){i=t;for(let r of p)r(t);}}function D(t){if(!t||typeof t!="object")return;let{mode:r,tokens:d,fontFaces:y}=a,g=false;if(t.theme!=null){let w=h(t.theme,r);w!==r&&(r=w,g=true);}let I=t.styles;I?.variables&&typeof I.variables=="object"&&(d={...d,...I.variables},g=true);let K=de(y,t);if(A(K)!==A(y)&&(y=K,g=true),g){a={mode:r,tokens:d,...y?{fontFaces:y}:{}};for(let w of f)w(a);}}function E(t){if(m)return;let r=typeof t=="number"?t:Math.ceil(e.document.body.scrollHeight);r!==b&&(b=r,N(oe,{height:r}),x({type:B,payload:{height:r}}));}function me(t){let r=Number(t.id),d=u.get(r);if(d)if(u.delete(r),clearTimeout(d.timer),t.error!=null){let y=t.error;d.reject(new Error(y.message??"request failed"));}else d.resolve(t.result);}function ye(t,r){if(t===te){let d=r.structuredContent;z(d??L(r));}else t===ne&&D(r);}function ge(t){t.method===ie&&P({jsonrpc:"2.0",id:t.id,result:{}});}function he(t){if(t.type===$||t.type==="renderData"){let{theme:r,...d}=t.payload??{};r!=null&&D({theme:r}),Object.keys(d).length>0&&z(L(d));}}let j=t=>{if(m||t.source&&t.source!==H())return;let r=t.data;if(!(!r||typeof r!="object")){if(r.jsonrpc!=="2.0"){he(r);return}r.id!=null&&("result"in r||"error"in r)?me(r):typeof r.method=="string"&&(r.id!=null?ge(r):ye(r.method,r.params??{}));}};function Te(){k=()=>E(),e.addEventListener("resize",k),typeof e.ResizeObserver<"u"&&(v=new e.ResizeObserver(()=>E()),v.observe(e.document.body));}return {host:"claude",getData:()=>i,onData(t){return p.add(t),()=>p.delete(t)},getTheme:()=>a,onTheme(t){return f.add(t),()=>f.delete(t)},async callTool(t,r){return await C(ae,{name:t,arguments:r??{}})},sendPrompt(t){C(se,{role:"user",content:[{type:"text",text:t}]}).catch(()=>{}),x({type:Q,payload:{prompt:t}});},openLink(t){C(re,{url:t}).catch(()=>{}),x({type:q,payload:{url:t}});},resize(t){E(t);},capabilities(){return {pull:true,sendPrompt:true,openLink:true}},start(){e.addEventListener("message",j),i=S(e.document,n),s&&Te(),P({type:Z}),C(J,{appInfo:{name:o.name??"synapse-ui",version:o.version??"0.0.0"},appCapabilities:{availableDisplayModes:["inline"]},protocolVersion:X}).then(t=>{m||(c=true,D(t?.hostContext),N(ee,{}),b=-1,E());}).catch(()=>{}),E();},destroy(){if(!m){m=true,e.removeEventListener("message",j),k&&e.removeEventListener("resize",k),k=null,v?.disconnect(),v=null;for(let t of u.values())clearTimeout(t.timer),t.reject(new Error("adapter destroyed"));u.clear(),p.clear(),f.clear();}}}}function De(e){if(e.openai!=null)return "chatgpt";try{if(e.parent!=null&&e.parent!==e)return "claude"}catch{return "claude"}return "generic"}function Fe(e,o,n){switch(e){case "chatgpt":return ce(o);case "claude":case "nimblebrain":return pe(o,n);default:return le(o,n)}}function ue(e,o){let n=o.host??De(e);return Fe(n,e,o)}function fe(e={}){let o=e.window??globalThis,n=ue(o,e);R(n.getTheme());let s=n.onTheme(R);n.start();let i=false;return {data:()=>n.getData(),onData:a=>n.onData(a),theme:()=>n.getTheme(),onTheme:a=>n.onTheme(a),callTool:(a,p)=>n.callTool(a,p),sendPrompt:a=>n.sendPrompt(a),openLink:a=>n.openLink(a),resize:a=>n.resize(a),capabilities:()=>n.capabilities(),host:()=>n.host,destroy(){i||(i=true,s(),n.destroy());}}}globalThis.SynapseUI={connect:fe};})(); \ No newline at end of file diff --git a/src/__tests__/theme-font-faces.test.ts b/src/__tests__/theme-font-faces.test.ts index 8a6d9e0..1c4bb11 100644 --- a/src/__tests__/theme-font-faces.test.ts +++ b/src/__tests__/theme-font-faces.test.ts @@ -95,7 +95,7 @@ describe("web-safe defaults — the no-host guarantee", () => { // with no host, no network and no font files still renders correctly. expect(tokens.fontSans).toContain("system-ui"); expect(tokens.fontMono).toContain("ui-monospace"); - expect(tokens.fontHeading).toContain("Georgia"); + expect(tokens.fontHeading).toContain("system-ui"); // ...and carries no brand, current or historical. for (const value of [tokens.fontSans, tokens.fontMono, tokens.fontHeading]) { diff --git a/src/__tests__/ui/components.test.tsx b/src/__tests__/ui/components.test.tsx index 1abe636..36b2c8e 100644 --- a/src/__tests__/ui/components.test.tsx +++ b/src/__tests__/ui/components.test.tsx @@ -73,7 +73,7 @@ describe("Badge + Card", () => { it("renders a badge label and card content", () => { render( - HIGH + HIGH , ); expect(screen.getByText("HIGH")).toBeDefined(); diff --git a/src/__tests__/ui/tokens.test.ts b/src/__tests__/ui/tokens.test.ts index 5c16daf..d9c78b7 100644 --- a/src/__tests__/ui/tokens.test.ts +++ b/src/__tests__/ui/tokens.test.ts @@ -24,10 +24,105 @@ describe("token contract", () => { expect(tokens.fg).toBe("var(--color-text-primary, #111827)"); expect(tokens.fontSans).toContain("system-ui"); // not 'Satoshi' expect(tokens.fontSans).not.toContain("Satoshi"); - expect(tokens.fontHeading).toContain("Georgia"); // serif fallback, not 'Erode' + // Headings fall back to the same sans stack as body: one family, with + // hierarchy from weight and size. A serif heading fallback would reproduce + // the display-serif signature on any host that injects nothing. + expect(tokens.fontHeading).toContain("system-ui"); expect(tokens.fontHeading).not.toContain("Erode"); }); + it("backs every default with an unbranded value, never a brand hex", () => { + // The font side got a guard in 0.13.0 when a brand stylesheet import was + // removed; the colour side got none, which is how #d4620a — the warm accent + // from a NimbleBrain brand generation — sat across two releases in *both* + // unbranded-default maps, each under a docblock claiming otherwise. + // + // Both are checked here, because both are that claim: DEFAULT_THEME_VARS is + // the block the SDK injects, and `tokens` holds the `var()` fallback each + // component resolves against when a host declares nothing. A guard on one + // leaves the other free to reintroduce exactly what this test exists to + // catch. + // + // An allowlist rather than a denylist: the map is small and hand-authored, + // so the sanctioned set can be stated outright. A denylist only catches the + // brand values someone thought to name, which is how the last one got in. + // + // ADMISSION RULE, so this stays a guard and does not decay into a list to + // append to: a hex qualifies when it was not *chosen for a brand*. In + // practice every hue below is a stock Tailwind ramp step — blue-600, + // red-600/400, emerald-600/400, amber-500/400, violet-600/400, indigo-400 — + // and every `*-light` ground is a neutral tint of one of them. The test is + // not "no brand uses this hue", which nothing could pass: #7c3aed is + // violet-600 off the shelf and a brand happens to use it too. #d4620a fails + // because it is bespoke and brand-tuned, picked to be one product's warm + // accent and nothing else's. If a candidate is not a stock ramp step or a + // tint of one, it does not belong here — take it to the host instead. + const SANCTIONED = new Set( + [ + // Neutral ladder — surfaces, text, borders. + "#ffffff", + "#fafafa", + "#f3f4f6", + "#111827", + "#6b7280", + "#9ca3af", + "#e5e7eb", + "#d1d5db", + "#18181b", + "#27272a", + "#2f2f34", + "#a1a1aa", + "#71717a", + "#3f3f46", + "#52525b", + // A generic blue, and the generic semantic hues. + "#2563eb", + "#818cf8", + "#dc2626", + "#f87171", + "#059669", + "#34d399", + "#f59e0b", + "#fbbf24", + "#7c3aed", + "#a78bfa", + // Tints those hues are laid on. + "#f3eeff", + "#2a2440", + "#eef4ff", + "#1e2a44", + ].map((h) => h.toLowerCase()), + ); + + for (const mode of ["light", "dark"] as const) { + for (const [name, value] of Object.entries(DEFAULT_THEME_VARS[mode])) { + // Notation first, membership second. Scanning a value for hexes and + // checking only what turns up leaves every other CSS colour syntax + // unguarded — `rgb(212, 98, 10)` is byte-for-byte #d4620a and passed + // this whole file. Requiring a hex literal is what makes the + // membership check below total. + expect(value, `${mode} ${name}: expected a hex literal`).toMatch(/^#[0-9a-f]{3,8}$/i); + for (const hex of value.toLowerCase().match(/#[0-9a-f]{3,8}/g) ?? []) { + expect(SANCTIONED.has(hex), `${mode} ${name}: ${hex} is not a sanctioned neutral`).toBe( + true, + ); + } + } + } + + // No anchor here, and the extracting loop is load-bearing for it: these + // values are `var(--token, )` strings, not bare hexes, and the + // non-colour ones (font stacks, radii, weights) carry no hex at all and + // simply contribute nothing to check. + for (const [name, value] of Object.entries(tokens)) { + for (const hex of value.toLowerCase().match(/#[0-9a-f]{3,8}/g) ?? []) { + expect(SANCTIONED.has(hex), `tokens.${name}: ${hex} is not a sanctioned neutral`).toBe( + true, + ); + } + } + }); + it("maps the type scale to the matching size + line-height vars", () => { expect(textStyle("sm")).toEqual({ fontSize: "var(--font-text-sm-size, 0.875rem)", diff --git a/src/theme-defaults.ts b/src/theme-defaults.ts index f41e414..be40d5d 100644 --- a/src/theme-defaults.ts +++ b/src/theme-defaults.ts @@ -16,8 +16,10 @@ * - any var nobody declares still resolves to a theme-correct neutral value. * * It's the `var()` fallback, but able to branch on theme. The values stay - * deliberately NEUTRAL (grays + a generic blue), never NimbleBrain brand — - * brand arrives only by host injection, mirroring `ui/tokens.ts`. This keeps + * neutral grays, a generic blue, and generic semantic hues for danger, success, + * warning and processing — never a *brand* value. Brand arrives only by host + * injection, mirroring `ui/tokens.ts`. `__tests__/ui/tokens.test.ts` holds the + * line: every hex here must be in the sanctioned set. This keeps * the library host-agnostic while guaranteeing every token resolves correctly * in both themes even against an incomplete host, a standalone `connect()` * widget, or a third-party host. @@ -70,8 +72,6 @@ const LIGHT: Record = { "--nb-color-danger": "#dc2626", "--nb-color-success": "#059669", "--nb-color-warning": "#f59e0b", - "--nb-color-warm": "#d4620a", - "--nb-color-warm-light": "#fef5ee", "--nb-color-processing": "#7c3aed", "--nb-color-processing-light": "#f3eeff", "--nb-color-info-light": "#eef4ff", @@ -103,8 +103,6 @@ const DARK: Record = { "--nb-color-danger": "#f87171", "--nb-color-success": "#34d399", "--nb-color-warning": "#fbbf24", - "--nb-color-warm": "#fb923c", - "--nb-color-warm-light": "#3a2a1e", "--nb-color-processing": "#a78bfa", "--nb-color-processing-light": "#2a2440", "--nb-color-info-light": "#1e2a44", diff --git a/src/ui/components/Badge.tsx b/src/ui/components/Badge.tsx index 6c9e90e..e1b76f2 100644 --- a/src/ui/components/Badge.tsx +++ b/src/ui/components/Badge.tsx @@ -6,14 +6,7 @@ import type { HTMLAttributes, ReactNode } from "react"; import { tokens } from "../tokens.js"; -export type BadgeTone = - | "neutral" - | "accent" - | "success" - | "warning" - | "danger" - | "processing" - | "warm"; +export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger" | "processing"; /** `{ bg, fg }` per tone. Soft tinted background, same-hue label. The status * tones have no `*Light` token so their tint is derived from the fg color. */ @@ -24,7 +17,6 @@ const TONE: Record = { warning: { bg: `color-mix(in oklab, ${tokens.warning} 14%, transparent)`, fg: tokens.warning }, danger: { bg: `color-mix(in oklab, ${tokens.danger} 14%, transparent)`, fg: tokens.danger }, processing: { bg: tokens.processingLight, fg: tokens.processing }, - warm: { bg: tokens.warmLight, fg: tokens.warm }, }; interface BadgeProps extends HTMLAttributes { diff --git a/src/ui/tokens.ts b/src/ui/tokens.ts index 2a5f5d6..a854d7b 100644 --- a/src/ui/tokens.ts +++ b/src/ui/tokens.ts @@ -8,10 +8,13 @@ * with no React re-render: the host swaps the `:root` vars and every `var()` * re-resolves. * - * The fallbacks are deliberately **neutral** (system fonts, neutral grays, a - * generic blue) — NOT NimbleBrain brand. Brand arrives by injection when the - * app runs inside the NimbleBrain host; standalone/static renders get a sane + * The fallbacks are deliberately **unbranded** — system fonts, neutral grays, a + * generic blue, and generic semantic hues for the status channels. NOT + * NimbleBrain brand. Brand arrives by injection when the app runs inside the + * NimbleBrain host; standalone/static renders get a sane * unbranded default. This keeps the library host-agnostic. + * `__tests__/ui/tokens.test.ts` holds the line: every hex in these fallbacks + * must be in the sanctioned set, same as `theme-defaults.ts`. * * Components import the static `tokens` object and style with CSS `var()`, so * theming (incl. light/dark) resolves in CSS with no re-render. @@ -42,8 +45,6 @@ export const tokens = { danger: "var(--nb-color-danger, #dc2626)", success: "var(--nb-color-success, #059669)", warning: "var(--nb-color-warning, #f59e0b)", - warm: "var(--nb-color-warm, #d4620a)", - warmLight: "var(--nb-color-warm-light, #fef5ee)", processing: "var(--nb-color-processing, #7c3aed)", processingLight: "var(--nb-color-processing-light, #f3eeff)", infoLight: "var(--nb-color-info-light, #eef4ff)", @@ -51,7 +52,7 @@ export const tokens = { // ── Typography ── fontSans: "var(--font-sans, system-ui, -apple-system, BlinkMacSystemFont, sans-serif)", fontMono: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)", - fontHeading: "var(--nb-font-heading, Georgia, 'Times New Roman', serif)", + fontHeading: "var(--nb-font-heading, system-ui, -apple-system, BlinkMacSystemFont, sans-serif)", weightNormal: "var(--font-weight-normal, 400)", weightMedium: "var(--font-weight-medium, 500)", weightSemibold: "var(--font-weight-semibold, 600)", diff --git a/src/ui/typography.tsx b/src/ui/typography.tsx index feb1416..d992e60 100644 --- a/src/ui/typography.tsx +++ b/src/ui/typography.tsx @@ -1,7 +1,7 @@ /** * Typography components bound to the type scale and the sans/heading font * slots. `Text` for body copy, `Heading` for display type (uses the heading - * font — Erode in the NimbleBrain host, a serif fallback elsewhere). + * font — Hanken Grotesk in the NimbleBrain host, `system-ui` elsewhere). */ import type { CSSProperties, ElementType, HTMLAttributes, ReactNode } from "react"; diff --git a/web/src/components/ui/BadgeDemo.tsx b/web/src/components/ui/BadgeDemo.tsx index 1282afb..51223a1 100644 --- a/web/src/components/ui/BadgeDemo.tsx +++ b/web/src/components/ui/BadgeDemo.tsx @@ -11,7 +11,6 @@ export default function BadgeDemo() { Warning Danger Processing - Warm ); diff --git a/web/src/components/ui/tokens.ts b/web/src/components/ui/tokens.ts index b9edce3..b5fe398 100644 --- a/web/src/components/ui/tokens.ts +++ b/web/src/components/ui/tokens.ts @@ -72,8 +72,6 @@ const LIGHT: TokenMap = { "--nb-color-danger": "#dc2626", "--nb-color-success": "#16a34a", "--nb-color-warning": "#d97706", - "--nb-color-warm": "#ea580c", - "--nb-color-warm-light": "#fff7ed", "--nb-color-processing": "#7c3aed", "--nb-color-processing-light": "#f5f3ff", "--nb-color-info-light": "#eff6ff", @@ -98,8 +96,6 @@ const DARK: TokenMap = { "--nb-color-danger": "#f87171", "--nb-color-success": "#4ade80", "--nb-color-warning": "#fbbf24", - "--nb-color-warm": "#fb923c", - "--nb-color-warm-light": "#2a1408", "--nb-color-processing": "#a78bfa", "--nb-color-processing-light": "#1e1b2e", "--nb-color-info-light": "#0c1a33", diff --git a/web/src/content/docs/docs/components/badge.mdx b/web/src/content/docs/docs/components/badge.mdx index 9f9d55f..017cb42 100644 --- a/web/src/content/docs/docs/components/badge.mdx +++ b/web/src/content/docs/docs/components/badge.mdx @@ -1,6 +1,6 @@ --- title: Badge -description: A small uppercase pill for status and priority labels, in seven token-driven tones. +description: A small uppercase pill for status and priority labels, in six token-driven tones. --- import BadgeDemo from "../../../../components/ui/BadgeDemo.tsx"; @@ -20,9 +20,9 @@ import { Badge } from "@nimblebrain/synapse/ui"; ## Tones -Seven tones map to the recurring label meanings. `neutral` is the default; +Six tones map to the recurring label meanings. `neutral` is the default; `accent` for informational, `success` / `warning` / `danger` for state, and -`processing` / `warm` for the two extended hues. +`processing` for in-flight work. ```tsx Neutral @@ -31,7 +31,6 @@ Seven tones map to the recurring label meanings. `neutral` is the default; Warning Danger Processing -Warm ``` ## Props @@ -41,7 +40,7 @@ Seven tones map to the recurring label meanings. `neutral` is the default; | Prop | Type | Default | | --- | --- | --- | -| `tone` | `"neutral"` \| `"accent"` \| `"success"` \| `"warning"` \| `"danger"` \| `"processing"` \| `"warm"` | `"neutral"` | +| `tone` | `"neutral"` \| `"accent"` \| `"success"` \| `"warning"` \| `"danger"` \| `"processing"` | `"neutral"` | | `children` | `ReactNode` | None | | `…rest` | `HTMLAttributes` | None | diff --git a/web/src/content/docs/docs/components/fonts.mdx b/web/src/content/docs/docs/components/fonts.mdx index 99ed18a..797292d 100644 --- a/web/src/content/docs/docs/components/fonts.mdx +++ b/web/src/content/docs/docs/components/fonts.mdx @@ -52,7 +52,8 @@ also parses `src` against its own grammar, so relative paths, absolute URLs, and Omit `fontFaces` and the app renders in the web-safe fallbacks the [token contract](/docs/components/tokens) defines — `system-ui` for -`fontSans`, `ui-monospace` for `fontMono`, `Georgia` for `fontHeading`. +`fontSans`, `ui-monospace` for `fontMono`, and the same `system-ui` stack for +`fontHeading` — one family, hierarchy from weight and size. ### Give every font token a web-safe tail diff --git a/web/src/content/docs/docs/components/heading.mdx b/web/src/content/docs/docs/components/heading.mdx index 73beeed..805be5f 100644 --- a/web/src/content/docs/docs/components/heading.mdx +++ b/web/src/content/docs/docs/components/heading.mdx @@ -5,8 +5,8 @@ description: Display type bound to the host's heading font slot and type scale, import HeadingDemo from "../../../../components/ui/HeadingDemo.tsx"; -`Heading` renders display type using the host's heading font slot: Erode in the -NimbleBrain host, a serif fallback elsewhere. Size, weight, and color all resolve +`Heading` renders display type using the host's heading font slot: Hanken Grotesk +in the NimbleBrain host, `system-ui` elsewhere. Size, weight, and color all resolve from the injected token scale, so headings carry no brand of their own and match whatever host renders your app. The preview below is the real component from `@nimblebrain/synapse/ui`, live. diff --git a/web/src/content/docs/docs/components/tokens.mdx b/web/src/content/docs/docs/components/tokens.mdx index 644da92..8813b91 100644 --- a/web/src/content/docs/docs/components/tokens.mdx +++ b/web/src/content/docs/docs/components/tokens.mdx @@ -27,7 +27,7 @@ Every `var(--token, …)` in the library re-resolves against that block: --color-background-primary: #0b0b0f; --color-text-primary: #f4f4f5; --color-text-accent: #a78bfa; - --font-sans: "Satoshi", sans-serif; + --font-sans: "Your Brand Sans", system-ui, sans-serif; --border-radius-md: 0.5rem; } ``` @@ -92,8 +92,6 @@ background for badges, banners, and status rows. | `danger` | `--nb-color-danger` | `#dc2626` | | `success` | `--nb-color-success` | `#059669` | | `warning` | `--nb-color-warning` | `#f59e0b` | -| `warm` | `--nb-color-warm` | `#d4620a` | -| `warmLight` | `--nb-color-warm-light` | `#fef5ee` | | `processing` | `--nb-color-processing` | `#7c3aed` | | `processingLight` | `--nb-color-processing-light` | `#f3eeff` | | `infoLight` | `--nb-color-info-light` | `#eef4ff` | @@ -108,7 +106,7 @@ host supplies the faces. See [Host fonts](/docs/components/fonts). | --- | --- | --- | | `fontSans` | `--font-sans` | `system-ui, -apple-system, BlinkMacSystemFont, sans-serif` | | `fontMono` | `--font-mono` | `ui-monospace, SFMono-Regular, Menlo, monospace` | -| `fontHeading` | `--nb-font-heading` | `Georgia, 'Times New Roman', serif` | +| `fontHeading` | `--nb-font-heading` | `system-ui, -apple-system, BlinkMacSystemFont, sans-serif` | | `weightNormal` | `--font-weight-normal` | `400` | | `weightMedium` | `--font-weight-medium` | `500` | | `weightSemibold` | `--font-weight-semibold` | `600` |