Covers the two things a reader would otherwise assume wrongly: it does NOT pin
dark/light (separate axis), and it does NOT hide the inspector — a locked deploy
still shows it, which is the intended FDE configuration.
Says what the gate actually governs: the UI and routing expose only that skin.
It is a presentation/deploy gate, NOT a security boundary — all four agents stay
registered server-side, so another skin's agent endpoint remains reachable under
a lock.
Also corrects the stale "floating selector at the bottom-left" description; the
switcher is a dropdown at the top of the assistant column.
Co-Authored-By: Claude <noreply@anthropic.com>
The inspector's agentId-less /memories and /info requests stay keyed to
defaultSkinId even under a lock, so on a deploy locked to a non-default skin they
resolve a different scope than the running agent. Deliberate: the default
resolver is the one whose scope is seeded, so switching to the locked skin's
resolver would read empty on any skin without seed data. Only banking ships real
durable memory and it is also the default, so the two align in the configuration
that matters.
Co-Authored-By: Claude <noreply@anthropic.com>
The suite visits /airline and asserts all four switcher options, so a developer
with LOCK_SKIN set locally would watch it fail for reasons that look nothing like
the cause.
The pin only covers a server Playwright STARTS. reuseExistingServer means a warm
local run adopts an already-running pnpm dev and skips the whole env block, so
the comment documents both shapes: a non-banking lock 404s the hardcoded
/banking readiness probe and aborts at webServer startup, while a banking lock
gets through and fails the /airline assertions instead. In CI reuseExistingServer
is false, so the pin always applies.
Co-Authored-By: Claude <noreply@anthropic.com>
The dropdown is not rendered at all — no trigger, no chevron, no options in the
DOM. A disabled dropdown was rejected: it implies a choice that does not exist
and reads as a bug rather than as a single-tenant product. The badge is a div
with cursor:auto, no handler and tabIndex -1, so there is no dead control to
click or tab onto.
The identity block is defined once and rendered into either a button or a plain
div, so the two modes cannot drift apart. Swap-sides, hide, the skin-selector
testid the layout e2e keys off, and useSkinThemeReconcile's root all stay put.
Co-Authored-By: Claude <noreply@anthropic.com>
The tab read "CopilotKit Reskinnable Demo" beside the locked skin's own favicon,
leaking both "CopilotKit" and "demo" on the most visible surface a prospect sees.
generateMetadata brands the title AND the description from the locked skin, so
crawlers and link unfurlers see a coherent product page. A client effect cannot
do this: Next applies route metadata after hydration, so SSR always shipped the
demo strings.
force-dynamic here too. The root layout reads LOCK_SKIN and
PRESENTER_RESET_ENABLED per request and threads both into client gates;
correctness otherwise rested on the implicit invariant that every descendant
route happens to be dynamic. Cost is one dynamically-rendered /_not-found.
Unlocked metadata is byte-identical in both fields.
Co-Authored-By: Claude <noreply@anthropic.com>
Enforcement is one extra notFound() condition in SkinLayout. No middleware is
needed: notFound() throws during render, so a disowned skin never mounts a
provider, a thread or an agent registration. A redirect WOULD have needed
request-time middleware, and a route matcher there risks intercepting
/api/copilotkit's SSE stream.
Under a lock, /airline is as absent as /nope — uniform 404 semantics. / now
redirects to the locked skin, without which a locked deploy's front door would
land on defaultSkinId and 404.
force-dynamic on / is not optional: reading process.env is not a dynamic API, so
next build otherwise prerenders / and bakes the build-time skin into the
redirect. A deploy built unset then run locked sent / to a 404 front door.
Co-Authored-By: Claude <noreply@anthropic.com>
Same shape as the presenter-reset gate: the server env is read in the root
layout and passed down through a small context. The context default is null
(unlocked) so any subtree without the provider — including SelectorCard's bare
unit tests — behaves exactly as before.
isSkinLockedOut is a named predicate rather than an inline comparison because
inverting it would 404 every skin on an UNLOCKED deploy. Extracted, it gets
exhaustive mutation-sensitive tests without rendering SkinLayout and mounting
CopilotKitProvider.
Co-Authored-By: Claude <noreply@anthropic.com>
A per-deploy SERVER env, deliberately non-NEXT_PUBLIC_ like
PRESENTER_RESET_ENABLED, so one build serves both a locked single-tenant host
and the unlocked four-skin demo.
Throws on an unrecognised id rather than falling back to unlocked: silently
accepting a typo would 404 every skin AND send / to a 404 too, leaving the whole
app dark with nothing pointing at the cause.
Co-Authored-By: Claude <noreply@anthropic.com>
LOCK_SKIN must be validated, and a locked deploy's brand and tagline resolved,
from server components. Those cannot import registry.ts — it pulls in four
client skin modules. skins-config.ts is the import-free home for that, so the
data is duplicated there and fenced by a drift guard asserting it matches the
registry, which is what stops the copy rotting.
Co-Authored-By: Claude <noreply@anthropic.com>
Ports the banking demo's #6401 fix, which was never carried over to this app.
ApprovalButtons collapsed only on local `responded` state, which dies with the
component. These cards do get remounted when the run syncs, which resurrected
live Approve/Deny buttons on an action the user had already taken; clicking
them again fires a duplicate write against an already-settled call.
Adds a durable `resolved` prop, OR-ed with the local state so a click still
collapses without waiting for the round trip. It is passed from the tool call
itself at the three HITL renders that do not already early-return on status
"complete". The other three (offerWorkflowRecording,
awaitDashboardDemonstration, saveLearnedWorkflow) render their own terminal
card when complete, so they never reach the buttons and need nothing — which
is why banking also has exactly three call sites.
Verified against the banking skin in the browser: before, approving a policy
exception left a second card carrying live Approve/Deny; after, that card
reads "Response submitted." `pnpm lint` and `pnpm build` both exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Approve/Deny buttons could reappear on a card the user had already answered.
The clearest case is the teach arc: after "Open policy exception" is approved
and the chain moves on, that first card came back showing live buttons while
the later cards read "Response submitted."
"Response submitted." was purely local useState, so nothing tied it to the tool
call. Any remount lost it, and the earliest card in a multi-step chain has its
subtree replaced when the run syncs. Clicking the resurrected buttons would
fire a duplicate write against an already-settled call.
ApprovalButtons now takes a `resolved` prop carrying the durable signal from
the tool call itself, OR-ed with the local state: local collapses the buttons
immediately on click, `resolved` survives remounts and thread reloads. This is
the same result-over-status rule already applied to the PIN and charges cards.
Wires the 3 call sites that had no guard. The 3 that already collapse on
`result` are unchanged — there TypeScript correctly rejects the status
comparison as unreachable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skill documented how to wire the `Skin` contract and nothing about what
the resulting demo has to demonstrate. Tested by asking a fresh agent to plan
a new BI skin with only the old skill: it produced a technically sound plan
that missed most of the banking demo's beats — no rich-thread step, zero
`useAgentContext` readables, no stored-procedure or teach-mode arc — and
substituted its own thesis (RBAC governance) for the demo's. Contract
documentation says what is available; only choreography says what is required.
Adds demo-beats.md: the nine beats framed by what the audience must conclude,
each with banking's implementation cited, plus the presentation requirements
(a pill per beat so the presenter never types, a visible affordance on every
mutation, pretty prose, Reset, the chat-placement framing), the domain
shortlist and the quality bar. The beats are a strong default an explicit
instruction can override, expressed as a beat map with a row per beat that
records deliberate skips — omission failures respond to a slot you must fill,
not to exhortation. Re-running the same planning task with the reworked skill
covers all nine.
Also folds in what tracing banking turned up, all of it silent-failure class:
renders must key off the tool `result` and not `status` or they go blank on
thread replay, exactly when "reload and the chart is still there" is being
shown; "what's on my screen?" needs a route readable plus per-page on-screen
readables, which is why that beat is impossible in the three other skins; and
long-term memory is a seeded file, not emergent, so seed-memories.ts gets a
template — including why beat 6's procedure must never be seeded and why a
seeded procedure must run without a confirmation gate.
Corrects CLAUDE.md, which claimed four skins and then documented two. Now
covers all four across both substrates with a beat-coverage matrix, fixes the
`identifyUser` attribution (logistics and keel contribute one too), drops an
unsupported claim that a Panel's `id` becomes its `data-testid`, notes keel's
parameterized routes, and acknowledges the shell-mounted inspector that
replaced the glass engine.
Co-Authored-By: Claude <noreply@anthropic.com>
The reskin skill was the urgent part: it is the sanctioned path for adding a skin,
and it still taught two patterns the frame retired. It told authors to publish
--nw-nav-inset-left/right from a useEffect so the floating selector could dodge their
nav -- both the variables and that selector are gone -- and to root the layout at
h-screen overflow-hidden, which overflows the app card by the frame's padding.
Following it produced a skin with dead code and a broken root height. The nav-inset
step is removed with a short "Retired" note so it is not reintroduced by copying an
older skin, and the root guidance now explains that the CARD is the bound.
Adds a "The inset frame" section to CLAUDE.md covering what a reader needs before
touching src/shell/layout: the two-panel model and its bounds, why the thread rail is
a fixed element rather than a nested panel, the v4 API rename and the
id-becomes-data-testid behaviour, where the shell controls live, the fixed 12px card
radius, and the h-full requirement for skin layouts.
Also corrects the composition chain, the shell's ownership list, and the
four-skins-not-two count across CLAUDE.md, README.md and DESIGN.md, plus the skill's
verification step, which told authors to look for their skin in a bottom-left
floating pill.
Nine new tests over what only a real browser can see: panel order per docked side,
resize bounds in both directions, persistence across a reload and across a skin
switch, the rail collapse/reopen cycle, the hide/launcher cycle, that the selector
renders options only while open, that every skin's chrome fits its card without
scrolling the document, and that the chat header shows the active skin's assistant
name.
Weighted toward what actually broke. Every bug this layout shipped was a sizing or
resize behaviour invisible to jsdom, which has no layout engine: a rail collapsed to
zero width, a rail that could not be reopened, an assistant column that could be
widened but never narrowed, and a header showing the framework default. All four
passed a green unit suite. Each assertion was mutation-checked rather than assumed --
widening the assistant cap to 95% fails only the resize test, hardcoding the header
title fails only the header test.
Navigation goes through a gotoSkin helper that waits for the frame; the shell is
client-rendered, so reading geometry straight after goto returns an empty panel set.
Updates smoke.spec.ts for the dropdown: switching opens the menu first, and the
"no other skin's chrome leaked" guards get STRICTER -- from exactly-one selector pill
to zero occurrences, since a closed menu renders no other brand.
Retires the CopilotSidebar launcher from the fixme'd specs. a2ui-canvas clicked
copilot-chat-toggle, which no longer exists -- inert only because the spec never
runs, so it would have failed the moment anyone re-enabled it. memory-learning keeps
its guarded open-chat block deliberately: the count check makes it a no-op today, and
that spec needs the docker memory stack so it cannot be run here to verify a
behavioural edit.
Composes ShellFrame in the per-skin layout, passing the skin's Layout as its app
slot and the shared ChatPanel as its chat slot, replacing the sibling
Layout + ChatPanel + FloatingSelector trio.
Each skin's chrome drops its viewport height for h-full: it now fills the app card,
which the frame has already inset by its own padding, so a viewport-height root
overflowed the card by exactly that much. Logistics keeps overflow-hidden -- its nav
stays pinned only while the container is bounded, which is now the card. Banking
keeps its second, unrelated canvas-clearing effect and therefore its useEffect
import.
The four --nw-nav-inset-* publishing effects go with the floating selector they fed.
Each skin published the width of its own edge-nav so a floating pill could compute a
safe band and dodge it; the selector now occupies a slot in the assistant column and
overlaps nothing, so the whole mechanism is unnecessary and its component is deleted.
This is one commit because the halves are not independently correct: without the skin
edits the cards overflow, and deleting the selector before its import is removed
would not build.
Replaces CopilotSidebar with an inline CopilotChat so the cluster can live in a
panel. CopilotSidebar was a fixed <aside> that pushed document.body's margin and
faked two columns by being handed the width of both, insetting its own contents past
the rail while the rail painted into the freed strip as a separately-fixed sibling.
That deletes an entire geometry contract: --nw-chat-width, --nw-rail-offset and
data-nw-chat-open existed only to describe a fixed panel's footprint to the rest of
the page. Also gone: the force-open-on-mount ref dance, the header slot cast
(CopilotChat has no header slot -- that is a modal concern, so the header is now an
ordinary sibling), and the rail's own fixed positioning and translate animation.
The thread rail is a FIXED-WIDTH element, not a panel. v4's collapse API fought that
three ways: a collapsed 0 written to storage was restored forever, expand() restores
the "most recent size" which after collapsing to 0 is 0, and resize() is ignored
while collapsed. Conditionally rendering it makes isInboxOpen the single source of
truth with no imperative sync.
The header reads the SKIN for its title, not the chat configuration. As
CopilotSidebar's header slot it rendered inside the chat's own provider so our labels
reached it; as a sibling it reads the wrapper's, whose default "CopilotKit Chat" is
non-null and won the ?? chain -- every skin's header showed that instead of its
assistant name. The header is skin chrome now, and holds only conversation actions.
src/shell/layout/ owns the app's outer geometry: a padded region holding the
assistant column (selector card above chat card) and the skin's app card, separated
by a resizable gutter.
The model is deliberately just "one bounded panel, one that takes the remainder" --
assistant min 250px / default 600px / max 50%, app gets what is left. An earlier
version nested the thread rail as a resizable panel INSIDE the assistant column,
which made its floor a compound of rail + conversation and forced a derived
breakpoint, a switching collapsed floor and an app floor to compensate. Capping the
assistant as a SHARE rather than a pixel count is what removes the need for an app
floor and lets the mobile breakpoint stay a genuine 768px instead of being derived
from panel arithmetic.
- selector-card: the skin switcher as a dropdown, so its footprint stays flat as
skins are added, plus the shell controls -- swap sides and hide. All three are
shell concerns, which is why they are here and not in the chat header.
useSkinThemeReconcile stays on the card root: it reads the computed
--nw-dark-capable from inside the skin's theme root, and losing it would let a
light-only skin render dark chat chrome.
- layout-preferences: side and open state, shell-global so switching skins never
rearranges the workspace. Read through useSyncExternalStore because this repo
treats react-hooks/set-state-in-effect as an error, with write-through
persistence so only a deliberate choice is stored. The hook returns inert
defaults outside its provider rather than throwing.
- use-is-desktop: matchMedia through the same external-store pattern.
- panel-sizes: the three numbers, in one place.
Three changes to the shell stylesheet.
Re-scopes chat typography from the SDK's [data-copilot-sidebar] attribute to a
.nw-chat wrapper. That attribute came from CopilotSidebar, which the inset frame
replaces with an inline chat, so ~23 rule blocks styling assistant markdown would
have silently stopped matching -- a lint-clean, test-clean build with subtly wrong
chat text. The migration ran as a bracket: duplicate onto .nw-chat, land the
structural change, then delete the originals, with a guard test asserting first the
parity and then the absence. Typography is provably unchanged -- a probe mounting
markdown-shaped content with the library's prose class reported 140 identical
computed values across 14 selectors before and after.
Adds the frame's own rules: .nw-panel-card (a FIXED 12px radius, deliberately not
reading --radius, because the frame is shell chrome and must read identically in
every skin) and the chat cluster's fixed-width rail with the container query that
hides it when the card is dragged narrow.
Sets the root font size to 15px. The root, not body: every rem in the shell and all
four skins resolves against it, so this is the one lever that rescales type
everywhere without per-skin edits. Spacing scales with it by design; anything that
must not move is written in px. The chat prose moves 0.9375rem -> 1rem, since that
value existed to hit 15px under a 16px root and would otherwise sit below the new
default.
Deletes the ~120-line floating-selector dock and the unused .brand-text-gradient.
Pins ^4.12.2 and wraps its Group/Panel/Separator API behind two styled handles: an
8px gutter for the gap between cards and a 1px hairline. 4.x rather than the 2.x/3.x
used by six sibling examples because only 4.x accepts PIXEL size constraints, and
the layout's bounds are pixels.
Three v4 behaviours the consumers depend on, each with a test:
- The API is RENAMED from 2.x/3.x: PanelGroup->Group, direction->orientation,
PanelResizeHandle->Separator, autoSaveId->useDefaultLayout. Most material online,
including shadcn's Resizable block, targets the old names and will not compile.
- data-testid is DERIVED from a Panel's id and overwrites any passed in, so a
panel's id is its query handle. Getting this wrong makes every
[data-testid$='-panel'] query silently match nothing.
- Separator refuses flex-grow/flex-shrink overrides, so shrink-0 would be dead.
Adds a jsdom ResizeObserver stub, without which every test rendering a Group throws.
Note for anyone adding a dependency here: declare it in package.json and run
'pnpm install --lockfile-only' from the REPO ROOT. This example pins pnpm 10.10.0
while the root pins 10.33.4, so 'pnpm add' from this directory makes corepack switch
versions and the older pnpm re-resolves the entire workspace lockfile.
Two new suites, each written so it would have failed against the previous code
rather than merely describing the new behaviour:
- derived-spend: policies[].spent equals the sum of approved charges, excludes
pending/flagged (so the over-limit check cannot double-count the charge it is
gating), and agrees between policies() and findPolicy(). Plus the properties
the demo depends on — at least three distinct months (the condition whose
absence made the trend chart fabricate), AWS $15,000 still deriving
over-limit for the teach-mode pill, exactly one Delta charge, and a team and
category on every ledger row.
- charges-data: parseSort and parseTop reject the values that previously slipped
through as valid, and toChargeRow's over-limit projection only applies to
pending charges.
Confirmed red against the old code: parseSort("banana") returned "banana",
parseTop("-5") returned -5, the seed spanned two months, no row carried a team,
and spent was a stored field.
The four existing fixtures move from ExpenseRole to PolicyType for policy
`type`, following the team/policy split.
The regex that decides whether a transaction note gets a red-alert prefix was
anchored on the left only, so `report` matched inside "reporter" and "quarterly
report" and `disput` inside "disputation". A note reading "attached to the
quarterly report" was served a fraud marker.
Anchored on both sides and switched from stems to whole words, verified against
the four phrasings that previously false-positived.
Four defects in the report's charts, all reported by review and all
reproduced in the running app before fixing.
- SpendingTrendChart substituted a hard-coded [3200, 4100, 3600, 5200, 4800,
6400] Jan-Jun series whenever fewer than three months were present. Intended
as an empty state, it was the DEFAULT path: the seeded ledger spanned two
months, so the report's "Spend over time" always showed six invented numbers
— roughly 20x smaller than the total printed directly above them — under a
card whose own contract says every number comes from the live ledger. It now
charts whatever months exist, with a real empty state at zero.
- SpendBreakdownChart's docstring said the report must use SpendByTeamBars
instead, "because an attached invoice can push one team to ~96% and a donut
cannot survive that", while the report rendered the donut anyway. The warning
was real but its cause was the thin ledger, not the chart: against the old
$137,000 base a $900,000 invoice took one slice to 89%. Against the real
~$533,000 base the same invoice reaches 73%, and 89% would need ~$2.8M.
Robust because the data is real, not because a floor was added to the arc.
- augmentForReport built its synthetic transactions behind an `as Transaction`
cast that was hiding a real hole: `policyId` came from an `?.id` lookup, so it
could be undefined where the field is a required string. The cast is gone and
the compiler checks it. Additions also now resolve their model-authored team
to a policy envelope through `policyForTeam` rather than comparing a team name
to a policy name, with one "Unattributed" segment for unmappable names.
- A comment inside TopChargesChart claimed document-sourced charges "have no
policyId" — three lines above the code that gives them one.
The donut column is relabelled "Spend by policy", which is what it reads.
The skin held three disagreeing answers to "what did we spend": a 45-row
Charges fixture ($632,806), a 4-row seeded ledger ($30,089 across two
months), and static policy totals ($137,000). Each surface read a different
one, so they drifted silently — and because the ledger spanned only two
months, the report's trend chart fell back to a hard-coded series and showed
invented figures under a card that promises live numbers.
Now there is one ledger. The 45 charges live in seed.json as real
transactions across Apr/May/Jun, and the Charges page reads them over REST
like every other surface.
- Splits team from policy. A charge belongs to one of seven org teams; a
policy is one of three budget envelopes (Technology / Go-to-Market / G&A)
and several teams share one. These were a single `ExpenseRole` enum, which
is why the two axes read as one thing and why covering every team meant
either a seven-slice donut or discarding real charges. `ExpenseRole` still
types a member's own team; `PolicyType` types the envelopes, joined by
`policyForTeam`.
- Derives `policies[].spent` from approved charges on every read, so it can
no longer disagree with the charts. It also now MOVES: approving a charge
previously left `spent` untouched, so the budget never reflected the
approval and the over-limit gate kept comparing against a stale figure.
- Makes over-limit derived-only. A charge no longer stores "over-limit"; the
Charges table resolves the badge through `withOverLimit`, the same rule the
report uses, so the two cannot disagree.
- Validates the `?sort=` and `?top=` params. `?sort=banana` used to be cast
straight to a SortKey and lit the control's "active" tint while the table
silently sorted by the default; `?top=-5` reached `slice(0, -5)` and dropped
the LAST five rows, inverting top-N.
The scripted demo is unchanged by construction: the four demo-load-bearing
transactions survive byte-identical, over-limit is still exactly three charges
totalling $30,000, AWS $15,000 still derives over-limit for the teach-mode
pill, and Delta Airlines is still the only Delta charge (the fixture's near
-duplicate "Delta Air Lines" became United Airlines).
Follows the maintainer's Correction #2 on issue 6363. An exact version in a
docs install command is the same rot as the starlette==0.45.3 pin it replaced:
it goes stale silently and nobody re-resolves prose. The 2.22.0 the docs shipped
was already a version behind current the day it was written.
- docs install lines use pydantic-ai-slim[ag-ui,openai]>=2,<3, which constrains
the dep the pages actually care about and fails loudly at the v3 boundary
- ag-ui-protocol drops out of the docs lines entirely; no doc snippet imports
ag_ui, so naming it there was the transitive-dep noise the correction is about
- starlette>=0.46.2 stays, because the v2 snippets import Starlette directly.
A floor with no ceiling cannot force a downgrade, so it does not recreate the
silent backtrack
- examples/showcases/pydantic-ai-todos moves to a range in pyproject.toml and
relocks; the uv.lock is what reproduces
- examples/canvas/pydantic-ai keeps exact pins: it has no lockfile, so
requirements.txt is its only reproducibility artifact
Smoke-tested the open question from the issue: starlette 1.x works on
pydantic-ai v2. All 8 doc pages pass on 2.23.0 + starlette 1.3.1 and on
2.23.0 + starlette 0.52.1, so Jordan's <1.0 guard can be dropped rather
than raised.
Agent.to_ag_ui(), AGUIApp and the pydantic_ai.ag_ui module were removed in
Pydantic AI v2. The docs installed pydantic-ai unpinned, so anyone following
the quickstart got 2.22.0 and failed first at dependency resolution
(starlette==0.45.3 conflicts with the >=0.46.2 the ag-ui extra needs) and then
at AttributeError.
- 8 doc pages under showcase/shell-docs .../integrations/pydantic-ai serve the
agent from a Starlette route via AGUIAdapter.dispatch_request
- StateDeps moves from pydantic_ai.ag_ui to pydantic_ai.ui
- stateful snippets build StateDeps per request; dispatch_request writes the
client's state into deps.state, so a shared instance leaks state between users
- install commands exact-pin pydantic-ai-slim==2.22.0 and ag-ui-protocol==0.1.19
- examples/canvas/pydantic-ai and examples/showcases/pydantic-ai-todos ported
and pinned, todos relocked
- skills/copilotkit-integrations reference updated to the same shape
showcase/integrations/pydantic-ai is deliberately untouched; it is tracked
separately.
Resolves the append-only registry conflict this PR's description predicted:
#6302 (logistics / Meridian) landed first and took the same two lines, so
keel takes the trivial conflict. Both edits are additive, so the resolution
is keep-both in both registries — logistics first (it landed first), keel
second.
Why the merge was necessary rather than optional: GitHub does not create
pull_request workflow runs for a PR whose mergeable state is CONFLICTING,
because it cannot compute the merge ref those events run against. The push
of the review fixes therefore produced ZERO CI runs — verified over 12
minutes of polling the Actions API by head SHA, and corroborated by Actions
being healthy repo-wide at the same time. Not a timing artifact, and not a
path filter: test_e2e-legacy-v1 and auto_merge_showcases both trigger on
pull_request with paths: examples/**, and synchronize is a default activity
type.
Scope of the drift: main is 127 commits ahead of the merge-base, but within
reskinnable-demo it added only logistics' own files (48 skin + 17 API route
+ 2 reskin-skill docs) plus +7 append-only lines across exactly the two
registry files. No globals.css change, no skin-contract.ts change, no
skins-config.ts change — so the shared token vocabulary and the frozen Skin
contract are untouched, and keel's isolation claim still holds against a
four-skin registry.
All four skins are now registered under the same id in both registries
(banking, airline, logistics, keel), which is the invariant the two-registry
split exists to maintain.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
Remaining fixes across the ops-report builder, the a2ui canvas surface,
the catalog, agent wiring, suggestions, and the knowledge/playbooks pages:
correct the report surface output and its catalog registration so the
canvas renders the intended report, and tighten the surrounding chrome so
these entry points behave consistently. Tests cover the report builder and
canvas surface.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
Seed data used a fixed or module-load timestamp, so seeded run timelines
drifted out of a plausible window as time passed and could not be
reproduced deterministically in tests. This anchors every seeded run
relative to a `now` passed in at seed time, keeping the demo's timeline
coherent whenever it is generated and making the seed testable. A test
pins the relative anchoring.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
Dates and times were formatted with the runtime's ambient locale and
timezone, so server and client rendered different strings and React
hydration mismatched; several call sites also built ad-hoc formatters that
drifted from one another. This centralizes formatting behind pinned
locale/timezone formatters — one per format — so output is stable across
environments. It also fixes the ChatSurface pointer-events boundary so
interactive controls rendered inside the chat transcript (playbook,
approval, and run cards) actually receive clicks. Tests cover the pinned
formatters.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
URL route segments and client-forwarded runtime properties were used
directly as object keys, so a crafted value like "__proto__" or
"constructor" could resolve to an inherited Object.prototype member
instead of a real entry — misrouting a page or silently matching a bogus
user identity. This constrains both lookups to own, real keys so untrusted
input can no longer reach the prototype chain. Tests cover the malicious
key cases for page resolution and user identification.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
Search results depended on the runtime locale for tie-breaking, synonym
expansion chained transitively into unrelated terms, and phrase matching
hit partial tokens, so the same query could return different or wrong
results across environments. Citations could also duplicate and fail to
land on their source document. This pins result ordering to a
locale-independent comparison, bounds synonym expansion to direct
(non-transitive) matches, matches phrases on whole-token boundaries,
deduplicates citations, and makes a citation click land on the correct
document. Tests lock in the deterministic ordering and citation targeting.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
The data engine could report success for mutations that changed nothing
and exposed multiple divergent write paths, so callers could not trust a
returned status or reason about a gate from one place. This routes every
mutation through one shared commit path, returns values that reflect what
actually happened, and tightens the gates so a no-op or a rejected change
can no longer be misrepresented as applied. Types and the seed-backed data
hook are updated to match, with tests covering the corrected return
contract and persona-scoped access.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkMi54kqfNwAoUB3M3CBp3
The reskin authoring skill taught several patterns that ship a broken skin when
followed literally. Corrected against the debugged logistics skin (the frozen
contract wins on conflict):
- layout: h-screen overflow-hidden (not min-h-screen) + h-full aside, so the nav
stays pinned and <main> scrolls inside it
- layout: publish --nw-nav-inset-left/right with cleanup, so the floating skin
selector never docks on the nav and the inset does not leak between skins
- layout: document the meta-utility strip (Reset/ThemeToggle/Help) as
skin-authored chrome, with the reset-route gating coupling
- theme: document --nw-dark-capable as the dark-mode opt-in + a .dark .theme-<id>
example
- tools: every useComponent/useFrontendTool/useHumanInTheLoop registration must
close with a deps array, or the closure captures empty pre-fetch data forever
- tools: a parameterized useComponent render receives the schema output directly,
not { args }
- a2ui: a CanvasSurface must be fed by a server defineTool, never a client
useFrontendTool
- contract: nav is display-only; resolvePage is the sole segment validator
- fix airline useData contradiction; NPE-safe no-data tools shape; skin.tsx
dangling-import note
Co-Authored-By: Claude <noreply@anthropic.com>
The shell root used `min-h-screen`, which is a MINIMUM: on a page taller
than the viewport the container grew with the content, so the whole
document scrolled and the sidebar scrolled away with it. It also left
`<main>`'s own `overflow-y-auto` inert, because an unbounded parent gives
it no height to overflow against.
Switch the root to `h-screen overflow-hidden` and give the aside `h-full`,
mirroring banking's layout. The shell is now exactly one viewport tall, the
nav stays pinned, and `<main>` scrolls internally.
Measured on /logistics/lanes at a 700px viewport: document scrollHeight
700 (page no longer scrolls), aside top 0 / bottom 700, main scrollHeight
1472 vs clientHeight 700, and the aside stays at top 0 after scrolling
main by 700px.
Port banking's sidebar-footer utility controls into Meridian: a Reset
(presenter-gated), the shared ThemeToggle, and a copilot Help shortcut, placed
directly above the existing "On duty" planner switcher. Give the skin a warm-
graphite dark palette (--nw-dark-capable + a .dark .theme-logistics block) so
the theme toggle is a live control. Port useAskCopilot into the skin (no cross-
skin import) and widen the reset endpoint's gate to allow presenter/booth
deployments (presenterResetEnabled OR non-production).
Co-Authored-By: Claude <noreply@anthropic.com>
Applies the final whole-branch review wave for the logistics skin.
- renderBrief: move from a client useFrontendTool to a server defineTool on the
BuiltInAgent (mirrors banking's render_report). A client frontend-tool result
never yields an in-stream TOOL_CALL_RESULT, so the a2ui middleware never
emitted the a2ui-surface activity and the brief canvas stayed blank. Tool name
kept exactly "renderBrief". build-brief-ops + catalog/definitions confirmed
server-safe (plain Zod, no React/.tsx), so agent.ts stays server-safe.
- createDecisionRecord: implement the missing tool (globally registered) to log
a decision NOT executed through commitMitigation, wiring the previously-dead
fileDecision + POST /decisions path. Harden the route: require/resolve
plannerId, derive decidedBy/role server-side (never from body), 404 unknown
shipmentId, clamp costUsd. Client forwards plannerId only. Prompt + toolLabels
updated. Adds a decisions route test (planner-derived identity vs body decoys,
404, 400).
- greeting: drop the wrong "six lanes / three shipments" figures (seed has 10
lanes, 4 exception shipments); quantitative claims bind to data, not prose.
Co-Authored-By: Claude <noreply@anthropic.com>