The obvious design — "ask the model to write pptxgenjs code" — fails with a 12–26B local model. Slide layout is coordinate arithmetic, text metrics and overflow handling, which is precisely what small models are worst at. Worse, it is unstable: every regeneration reshuffles the geometry, so iterating on content destroys the design and iterating on design destroys the content.
The fix is to make layout something the model cannot touch.
┌──────────────────────────────┐
brief + sources ───►│ subtopics (knowledge-only │───► sections + owners
│ LLM, no research in prompt) │
└──────────────┬───────────────┘
│ one research pass per part
┌──────────────▼───────────────┐
│ research (tool-calling LLM) │───► notes.md, sources.json, pages.json
└──────────────────────────────┘
│
┌──────────────────────────────┐
│ outline (reasoning LLM, │───► outline.yaml
│ parts fixed — fills, never │
│ renames) │
└──────────────────────────────┘
│
┌──────▼──────┐
│ HUMAN GATE │ approve / edit / reorder
└──────┬──────┘
│
┌──────────────────────────────┐
│ content (reasoning LLM) │───► deck.yaml
└──────────────────────────────┘
│
┌──────▼──────┐
│ validate │ schema/deck.schema.json
└──────┬──────┘ errors ──► back to model
│
themes/*.yaml ────────────┐ │
▼ ▼
┌──────────────────────────────┐
│ render (deterministic) │───► deck.pptx
│ layouts + fit + chrome │
└──────────────────────────────┘
│
┌──────────────────────────────┐
│ preview (LibreOffice) │───► slide PNGs
└──────────────────────────────┘
│
┌──────────────────────────────┐
│ critique (vision LLM) │───► fix list
└──────────────────────────────┘
└──── loop back to content
src/render.js consumes the stable public registry in src/layouts.js.
Implementations live under src/layouts/ in core, lists/data, process/diagram
and special families; helpers.js owns shared geometry, fitting and drawing
primitives. Existing callers still receive the same layouts map and content
export.
Everything follows from this separation.
src/chrome.js. Department banner on the title slide, crest top-right on
content slides, presenter, slide number. Applied as a pass after the theme has
drawn, on every slide.
Why a separate pass: these marks are the only part of a deck that is actually graded. Making them a theme responsibility means a buggy theme can drop them. Making them a post-pass means no theme can.
The crest variant is chosen by the luminance of the background the layout painted — not by the palette — because section dividers and title slides deliberately break out of the standard page.
The content-slide footer's presenter is per-slide: slide.presenter (free text,
declared in the schema so the model's ops know it too) overrides whoever the
identity marks presenting, which only falls back to the team label. This is
content, not layout — the chrome reads the string, it never decides it.
themes/*.yaml, two disjoint halves:
tokens— exact machine values. Read only by the renderer. Hex colours, point sizes, inch offsets. No adjectives, no "or similar"; every value must be directly usable without interpretation.voice— tone, density, what to prefer and avoid. Read only by the model. No hex, no geometry.
The disjointness is load-bearing. If the model can see hex values it starts inventing them; if the renderer can see adjectives it starts guessing.
A theme also owns its composition, not only its colour. tokens.layout
(src/composition.js) is a small vocabulary of enums — where the content box
sits, what mark opens a slide, how a divider is composed, whether type is
flush or centred, which marker a list carries, whether a definition takes a
drop cap. Every axis defaults to the behaviour that preceded it, so a theme
declaring no layout block renders the file it always did; an unrecognised
axis or value throws rather than falling through, because a theme silently
losing its design is worse than a loud failure.
The axes are applied in three shared places — the opening mark, the heading
block and the content frame — which 67 of the 74 layouts already went through,
so one flag restyles all 73 slide types rather than needing per-type work. The
sidebar frame is the exception that proves the rule: it halves the measure,
and slide types whose body is a row of four or more peers keep the full canvas
under it, because four cards in two thirds of a slide break their words in the
middle and the fitter cannot see that — every fragment fits.
This replaced two ad-hoc flags (tokens.editorial, tokens.bauhaus) that had
proved per-theme layout works and were never generalised. Before it, the
themes were one composition in as many colourways, and the gallery contact
sheet is the evidence either way.
A theme is either native (pptxgenjs shapes and text only) or plated: it
declares tokens.plate.enabled: true plus a plate.html template (and optional
plate.surfaces.title/section/content variants) that interpolates its own
tokens as {{tokens.palette.bg}} — the renderer rasterises the template
through headless Chrome and uses it as the slide background, so a theme can
express effects OOXML cannot (backdrop blur, dual shadows, mesh gradients) while
all text stays native. Plate templates also receive {{box.*}} — the content
region in CSS pixels — so a theme can panel exactly the area the native content
draws, and native panels can be made translucent through shape.card_fill so
they layer over the plate instead of covering it.
Native themes can also carry a background layer: tokens.background.decor
is a list of native shapes (ellipse/rect/roundRect/triangle) drawn behind the
content box — a corner halo, an edge bar, a hairline grid. pptxgenjs has no
gradient fills (a gradient option is silently dropped), so any theme that wants
a true mesh or wash uses the plate path instead; the decor layer is for
treatments expressible as plain translucent shapes. drawBackground() in
render.js reads it and nothing in deck.yaml can reach it — same three-layer
boundary as the palette.
The gallery sits at 34 themes: the native reference (warm-humanist), a
broad native family (Swiss grid, editorial magazine, Muji, neubrutalism,
brutalist paper, Bauhaus, Memphis, Art Deco, Linear, Material You, newsprint,
editorial serif, Notion, Alegria, corporate blue, nature organic, blueprint,
high-contrast mono, mono terminal, risograph, letterpress, paper pastel,
chalkboard, sci-fi HUD) and plated ones (glassmorphism, claymorphism,
neumorphism, aurora-mesh, soft glass, sunset, gradient mesh dark, isometric
dark, retro CRT). It sat at 38 until four were culled as duplicates of a
survivor rather than designs of their own — a theme earns its place by being a
different design, and palette alone is not one. Theme cards are REAL renders — two surfaces
side by side, a title and a body slide, produced by npm run gallery from the
neutral specimen deck with institutional branding off, so the card shows the
design and never an institution. That branding is why they were once switched
off, leaving every card a flat block of the title colour: two themes with
similar title grounds became indistinguishable, and type, which is most of what
separates these designs, was not shown at all. The images are committed —
without them the picker falls back to a token synthesis that cannot show a
plate background, and regenerating 34 themes needs LibreOffice and several
minutes, which is the wrong thing to do on a container's first boot. The
type-swap gallery's specimen slides are neutral the same way (src/specimens.js
carries hand-written payloads rather than reaching into demo decks, which is
what broke when those decks were pruned).
Layout-LEVEL distinctness is carried by per-theme flags the layouts read
(tokens.editorial, tokens.bauhaus) and falls back to the default when
absent. There are only two such flags, and that is the honest limit of the
current gallery: the plate themes differ in what their background is, and the
thirty native themes are one composition in thirty colourways. Palette and
typeface are not enough to make a design feel like its own. The themes rely on the fitter's family
table — its per-family advance estimates are what keep a one-line title on one
line — and on their section-surface luminance, which the chrome's
crest/footer variant derives from. A theme-contract test loads every theme and
machine-checks the split, including the background-decor shapes.
Styles layer over themes. styles/*.yaml are cross-cutting token
overrides — deep-merged over a theme's tokens at load time, with voice merged
the same way — so a deck renders as theme × style without theme copies. A
style never declares a whole theme; it adjusts density, type scale or margins
on top of whichever theme is chosen. loadTheme(name, { style }) is the single
place this happens.
schema/deck.schema.json. Semantic slide data: type, headline, bullets, card
bodies, chart series. No coordinates, colours, fonts or sizes anywhere.
The model chooses what to say and which of 15 types fits each beat, freely. It cannot choose where anything sits.
The vocabulary is now the full 75-type enum: the sixteen original types, the
six Tier-1 additions, and the rest of the plan's families (Foundation closers,
List & Grid, Data & Stats, Comparison, Process & Flow, Timeline & Quote,
Callout, Image & Visual, Table & Matrix, Diagram-ish, Definitions, Team and
Special) plus freeform as the escape hatch. Each type is a conditional schema
block declaring data only, a native layout in src/layouts.js, and an editor
descriptor. The chart kind enum covers scatter, radar and stacked-bar
alongside the originals, and a cross-cutting speaker_note field draws a note
bar above the chrome footer on standard content slides.
The layout discipline for the new types follows the Tier-1 lessons: every
stacked element is fit-scaled or reserves its rendered line count, stat values
use fitOneLine (width-based, pessimistic) because the height-based fitter is
optimistic for wide digits, and the algorithmic diagram types (framework,
dependencies, diagram, hierarchy, venn) place nodes purely
from angles, radii and topological depth — never from content. A node's text
zone never sits under another shape: the framework ring is checked for card-vs-
ellipse and card-vs-card clearance at the largest size the box allows and falls
back to a stacked panel + grid when a ring cannot fit; the diagram sizes nodes
from the layer spacing (auto-routing a thin chain horizontally) and draws edges
before nodes, from a node's edge rather than its centre. When the layout
genuinely cannot hold a body line at the floor, it drops the body (label-only)
instead of shipping a tiny font. The fitter itself is em-aware (advance ×
size/72, with a weight factor for Black faces) and every text role has a
readable floor — the fitter clamps at the floor and reports "would need Xpt"
into the render problems rather than shrinking below it. Stacked zones size to
the content's real line count, so long card titles keep their lines instead of
being shrunk to ~8pt.
The pipeline is a fixed sequence with LLM calls at known stations, not open-ended agentic exploration. A framework would add indirection without adding capability. With small local models every prompt token matters and needs to be directly visible and editable — frameworks hide exactly that. Plain Node over Ollama's HTTP API is less code and far easier to debug.
src/ai/ollama.js is a role-addressed client, not an Ollama-only client. Each
role in config/models.yaml resolves to a backend:
ollama(default) — native/api/chat, withformatcompiled to a decoding grammar. This is the constrained-decoding path the whole pipeline is built around.openai-compatible(opt-in, per role viaprovider:) —/chat/completions,response_format: json_objectinstead of a grammar. Streaming and image parts work the same; a cloud role simply relies on a strong model obeying JSON rather than a decoder forcing it. Amodeloverride naming one of a provider'smodels:list routes that request to the provider — the picker's cloud group — while keys resolve env-first then from the gitignoredconfig/local.yamlstore (src/cloud.js).
Everything downstream (chat/chatJSON, generate, turn, pipeline, CLI, API)
is backend-agnostic — only the role's provider changes. The default stays
local; nothing calls a cloud endpoint unless a role opts in.
A role's sampling, output and context settings are written for the LOCAL
backend — the caps a small-model pipeline survives on. A role may declare
transports.cloud (and optionally transports.local) overrides in
config/models.yaml; applyTransport merges the block for whichever backend
the role resolves to, replacing nested blocks wholesale. This is how the cloud
path escapes limits that only exist because the local model cannot handle more:
- the author's
num_predict(12000 local / 24000 cloud) and researchexcerpt_chars(80000 / 240000) are per-transport; - the research role's
research:block sets the depth budget per transport — a cloud research role searches and reads far deeper; - a completion cut at
done_reason=lengthretries with the cap doubled up todefaults.num_predict_bump_ceiling, so a legitimate large output is not failed by a cap that was sized for a smaller model.
A BYOK cap guards the run, never kills it mid-deck. The per-call
reservation (reserveByokCall in src/byok-budget.js) throws 429 the moment
one call would cross the daily budget — correct for a chat turn, fatal for a
twenty-slide write. Generation requests (plan, write, finalize, report) run
inside withByokRun: one reservation for the segment's whole estimate, taken
upfront so an unaffordable run fails fast with numbers before burning
anything, then per-call actuals accumulate into the run (currentByokRun, an
AsyncLocalStorage like the account and the meter) and settle once at the
end. Over the cap with no explicit allowOverrun, the UI offers continue-on-
your-own-key rather than a dead deck. Local stays unmetered; Auto keeps hard
caps — that is operator money, and nothing here touches src/limits.js.
Prompts are per-transport too: authorTransport() tells prompt-builders which
backend the author will run on, so the cloud writer gets a full-strength
synthesis note (claim-first prose, real hooks, figures from the notes) while
the local writer keeps the conservative instructions that small models survive
on.
thinking and reasoning_effort are role settings, and they reach two very
different backends. The openai-compatible path sends the gateway's
chat_template_kwargs.enable_thinking alongside a reasoning_effort, and only
where the provider declares supports_thinking — an unknown top-level key being
ignored is a convention, not a contract someone's BYOK endpoint owes us.
The Ollama path sends think, and asks the model what it can do rather than
trusting the name: /api/show reports thinking among its capabilities, the
same probe roleCanSeeImages uses for vision, and sending think to a model
without it is an error rather than a no-op. reasoning_effort is written in the
gateway's vocabulary, so xhigh maps to Ollama's top level instead of being
passed through and rejected.
The flag is sent in BOTH directions, and that is the part worth knowing. A
thinking-capable model reasons by DEFAULT, so omitting the flag never meant
"off" — it meant "whatever the template does". Measured: the research role,
which declares no thinking anywhere, was returning ~17,000 characters of
reasoning on every call. A config consulted only when it says yes is not a
config. Sending think: false explicitly took that same call from 27 seconds to
3, on a role that runs many times per deck.
The result carries thinking back, on both the streamed and non-streamed paths,
for the same reason it carries fellBack and transport: a run that cannot say
whether it reasoned cannot be compared with one that did. It is never streamed
onward to a caller's onToken — the user is being shown an answer, not a
deliberation.
FORGE_HOSTED is the only fork. isHosted() (src/cloud.js:15) checks config/hosted.json (admin runtime toggle, file wins) then process.env.FORGE_HOSTED/FORGE_DISABLE_LOCAL, so the same image runs both ways:
FORGE_HOSTED=1(hosted):autoProvider()returns the gateway'sqwen3.6whenFORGE_AUTO_API_KEYis set (or the legacyFORGE_TCET_API_KEY, still read) andnullotherwise — no local Ollama fallback.resolveRole()andmodelChoices()hide local models,chat()forrole=authorsurfaces a clear "Hosted auto not configured — switch to Cloud" error instead of "Ollama unreachable". Internal roles (research/critic/utility) fall through Auto → Cloud when hosted.- unset /
0(local clone):autoProvider()falls back tolocalhost:11434Ollama,modelChoices()lists installed models, and the author route falls back to the local default.docker-compose.app.ymlwiresFORGE_HOSTED+ provider keys into the container;config/models.yamlheader documents the switch.
The admin UI (#/admin → System) and Settings → Deployment mode both call POST /api/admin/hosted (src/cloud.js:setHosted) which writes config/hosted.json and reloads the frontend (forge:hostedChanged + window.location.reload()), so Chat's hosted/local badge (ChatView.jsx:1015) and Local·Ollama / Auto·TCET pill (ChatView.jsx:944) plus the Sidebar Auto/Cloud toggle reflect instantly. No fork, no second branch — one repo, one image, one env var.
The two modes deliberately use different Compose entry points. The root
compose.yaml is the private self-hosted product: it binds
Forge to 127.0.0.1:8090, sets FORGE_HOSTED=0, keeps decks/accounts/BYOK keys
in the forge_local_data volume, starts an unexposed SearXNG companion, and
generates a random /data/config/local-key-pepper on first boot if the user did
not supply one. A fresh volume exposes one setup registration: that account is
created verified as an administrator, receives a session immediately, and
closes registration behind it. Existing volumes keep all of their accounts and
go directly to login. Local mode omits Google sign-in, address confirmation and
mail recovery rather than presenting flows that cannot work without SMTP;
FORGE_LOCAL_MULTI_USER=1 is the explicit compatibility escape hatch for a
trusted shared-machine install. Retention sweeps are disabled because a
personal install must not surprise its owner by deleting work.
Ollama is intentionally outside that bundle. FORGE_OLLAMA_HOST overrides the
normal models.yaml host at the transport seam, so a container can reach
host.docker.internal (Docker Desktop, or Linux's explicit host-gateway
mapping) without copying or rewriting the user’s model configuration. A trusted
LAN endpoint can be supplied with the same variable. Settings → Cloud is still
available, so BYOK works in a downloaded local install as well as in hosted
mode.
docker/docker-compose.app.yml is the
separate internet-facing deployment: hosted mode, explicit FORGE_KEY_PEPPER,
TLS/Caddy profile, mail and retention/operator controls. The image is shared;
the security posture is not. The final runtime image also copies
app/gallery, because the API serves theme-picker thumbnails from there and a
build-only copy left Docker users with a working app but no thumbnail assets.
Presets guess the deck's structure in advance and are wrong whenever the material does not fit the template. Reviewing the outline lets the model propose structure freely, because nothing reaches the renderer unapproved. The UI is the guardrail; presets survive only as a soft density hint the model may ignore.
A chart is the one place in a deck where colour is information rather than
decoration, and the renderer treated it as decoration. Series took their
colours from [accent, accent_alt ?? ink, ink_muted, rule] — two of those are
deliberately low-contrast, ink_muted being secondary text and rule a
hairline — so the third series was always washed out and the fourth sat almost
on the background. On high-contrast-mono, whose accent is black and whose
accent_alt is white, a four-series chart drew three identical black series
and one white one that vanished into the page. The chart claimed to show four
things and showed three.
src/chartpalette.js makes the palette a theme concern with a floor under it.
A theme may declare tokens.chart.series; when it does not, a palette is
derived that is at least legible — hues chosen greedily against those already
on the chart for a chromatic theme, a value ramp for a monochrome one, every
entry held above 3:1 against its own background. Greedy selection rather than
fixed rotations, because a fixed table collided with a theme's own accent_alt
and produced two series the same colour.
high-contrast-mono declares its palette explicitly. Greys alone cannot carry
four series at a readable contrast, so it uses flat poster primaries — the
language its display face and hard black rules already speak — with ink
leading, so a single-series chart still reads as that theme. A monochrome theme
that declares nothing still gets greys: the derivation must not colourise a
design that is deliberately colourless.
A contract test holds every theme to this in both modes, checking each series against the ground it is drawn on and against its neighbour, with the neighbour rule relaxed where hue already does the separating.
Native (default). pptxgenjs shapes and text. Editable in PowerPoint, selectable text, small files. Constrained to what OOXML can express.
Plate (opt-in). Headless Chrome renders CSS to PNG, placed as a slide
background with native text on top. Needed for effects OOXML cannot express —
backdrop blur, mesh gradients — and for type: freeform, where the model writes
arbitrary HTML.
The trade-off is real and drives the default: plate slides are images, so text in them cannot be corrected by whoever opens the file later. Correct for one or two hero slides; wrong for a whole deck.
src/plate.js is the single seam both plate themes and freeform slides use:
renderPlate(html, { w, h, scale }) writes an HTML document to a temp file and
screenshots it with system google-chrome-stable (--headless=new, no npm
browser dependency), returning a PNG cached in .plate-cache/ under a sha256 of
(html + viewport + scale + version). A changed document therefore gets a fresh
plate automatically; an unchanged one is free on every later render. The page
is sandboxed by a CSP that allows only inline styles and local data:/file:
images and fonts, so scripts and all network schemes are unrepresentable — the
HTML renders in a real browser, so anything not allowed is simply absent.
Which plate a slide gets is decided in render.js and falls out of the theme
or the slide, in that order of precedence:
- a slide-level
htmloverride (the freeform seam) wins; - otherwise the theme's
tokens.plate, whenenabled: true, suppliesplate.htmlwith optionalplate.surfaces.title/section/contentvariants; its template interpolates{{tokens.palette.bg}}-style paths against the resolved theme (mode-adjusted), keeping the theme YAML the single source of colours.
Plate themes panelled the content area by interpolating {{box.*}}, the content
region, so a headline began exactly on the panel's edge and the cards in a
comparison touched it. The panel read as a backdrop the text was pasted over
rather than a surface the content rests on.
Where a panel's edge sits relative to the content is renderer geometry, not a
per-theme decision, so plateHtmlFor supplies {{panel.*}} — the content box
grown by a fixed inset — alongside {{box.*}}. A theme still decides whether it
draws a panel and what it looks like; it no longer works out where the edge
goes, and seven themes stopped repeating the same calc() arithmetic.
Themes must leave room for the overhang in their margins. Only a little: the
vertical flow spine fits its step bodies within about 0.02 in of the readable
floor, and a wider step costs two themes a slide.
The plate exists for effects OOXML cannot express, and the temptation is to reach for blur because blur is the effect a browser makes easiest. Eight themes had done exactly that, and the result was that they all looked like each other.
Blur is not a style. It is what a material does to what is behind it, which means there has to be something behind it:
- Frosted glass needs hard edges to frost. Blurring a blur leaves nothing to
frost, and the slide becomes fog —
glassmorphismandsoft-glass-lightboth now set crisp shapes behind the panel, so the same shape is sharp on the ground and soft through the glass, both visible in one image. - A continuous colour shift cannot be built from separate light sources.
Each blurred blob is a single hue, so what happens between two of them is an
overlap, not a shift.
aurora-meshandgradient-mesh-darkdraw one element filled with one gradient running its length, shaped by a mask — fading the colour out instead dissolves the hue into the ground before it reaches the edge, and the travel is the whole point. - Extrusion and soft UI are shadow geometry, not luminosity.
claymorphismreads as clay because of a drop shadow and a bright inner rim;neumorphismis light falling across a surface, which is the opposite of a glow, which is light coming out of one.
A smooth gradient bands into visible steps at slide scale, so these carry a grain overlay — coarse enough to survive LibreOffice rasterising the preview at half the plate's resolution, which an earlier finer grain did not.
type: freeform is a real schema type, not a hidden flag: it requires a
non-empty html field, every other type forbids it, the writer prompt
describes the sandboxed subset, and the whole slide rasterises — there is no
native layout at all. The outline review and slide editor state the trade-off
(no text editing in PowerPoint afterwards) at the point of choosing.
The plate is set as the true slide background (slide.background = { data })
after the layout paints, so it sits under every shape and the chrome. Chrome
legibility on a plate slide is derived from the plate's own top-right corner
luminance (a sharp sample of the raster), falling back to the surface's
declared bg — for a freeform plate the theme's flat palette says nothing
about what was actually painted.
A written .pptx proves the file parsed. pres.writeFile() succeeds for a deck
whose text runs off the canvas, whose caption is drawn on top of its own title,
and whose headline was never drawn at all. So the renderer carries its own
instruments, and they exist because each was added after the ones before it were
clean on something that shipped.
They answer four different questions, and the order matters — a box that is not on the slide makes every later answer meaningless.
| question | instrument | where |
|---|---|---|
| Is the box on the slide? | the geometry watcher | src/geometry.js, inside every render |
| Was the field drawn at all? | the draw check | src/drawcheck.js |
| Does the text fit its box? | the fitter and the cap prober | src/fit.js, src/capfit.js |
| Did it survive the render? | the text check | src/textcheck.js |
The geometry watcher wraps the pptxgenjs slide rather than living inside the
layouts: 75 layouts cannot each be trusted to remember, and a rule enforced in
one place is a rule. It proxies every add* call, records the geometry, and
reports what cannot be right — a non-positive extent for any shape, and the
canvas edges for text and images. A decorative shape may bleed off the canvas on
purpose; a content shape that ran off the page takes its label with it, so the
text box reports and nothing is lost by the exemption. Rotated boxes are judged
on the footprint they render, not the one they declare. Because it runs inside
render(), npm run themematrix answers this question for the whole gallery
for free.
The draw check asks what no fit sweep can. Text that is never drawn is never fitted, so it never appears in a fit verdict, and it never reaches the raster for the text check to miss — a layout can discard content the model wrote on all 34 themes with every other instrument clean. It writes a marker into each field the schema declares, renders, and reads back the strings the layout emitted. Its second output is the more important one: the fields the specimen does not populate, which are the fields nothing has ever rendered.
The fixture is the limit on all of them. Every instrument reads a specimen
deck (src/specimens.js) of one slide per type. A field the specimen omits
cannot be measured, grown, marked or read back — so the question to ask of the
fixture is always what it does NOT carry. standfirst was absent on 71 of 75
types and no cap, sweep or render had ever carried one.
A sweep looks at eight themes. The layouts branch on composition axes — the
frame, the opening mark, the heading alignment, the list columns, the plate —
not on themes, so a set carrying every value of every axis renders every branch.
src/coverage.js holds that set and test/coverage.test.js fails when a theme
introduces a value none of them carries. The alternative is 2,550 slides, which
means the visual sweep does not happen.
They are not two flavours of the same artefact.
| Deck | Report | |
|---|---|---|
| Design freedom | total, only chrome is fixed | none, template is graded |
| Structure | model proposes, human approves | the donor template's section list |
| Renderer | pptxgenjs from theme tokens | donor .docx, body injected |
| Themes | 20 | not applicable |
Both are generated from the same research pass, so one brief produces both — which is the actual submission workflow.
A DECK's structure is an approved split; a report's is not. In the
subtopic flow (mode: team | solo) the model first splits the topic into
ordered parts from knowledge alone (src/ai/subtopics.js) — team mode
defaults to the presenting members however many there are, solo mode to an
explicit count, and an explicit subtopicCount wins in either mode — then
research runs per part and the outline fills exactly those parts (planDeck
with fixed sections, every part guaranteed at least one slide). Part counts
have no ceiling: the per-call model-schema bounds are set to the requested
count, and only the floor (at least one part, at least three sections by
default) is fixed. Each part has an owner (ownersFor: contiguous shares, one
member may own several), carried on plan.owners into distributePresenters,
which honours it whole-section rather than rebalancing. The outline gate edits
the parts (rename, add freely, remove with re-homing, reassign owners) before
anything is written. Legacy callers that pass no mode keep the old rule:
max(3, members) from the merged identity's team.
The report planner deliberately does NOT do this, and the deck's rule does not
transfer: a talk is divided between the people giving it, while a report's
structure is set by the institution grading it, and one author writing up a
large project owes the same sections as six. It used to share the calculation,
floored at the four-section graded core — which made the substance unreachable,
because the floor counted the TOTAL sections while the prompt separately
requires that same four-section core. Every team of four or fewer therefore
spent its whole budget on the frame, and report-new, solo by construction,
could only ever emit Abstract/Introduction/Conclusion/References.
Presenters are assigned centrally, not by the model. distributePresenters
in src/ai/team.js is the one place the split is computed: content slides
group by their section and whole sections go to presenting members in order,
so every member's slides are a single contiguous block (their section's divider
opens it, the next divider closes it) and block sizes differ by at most one
slide where the section sizes allow. With at least as many members as sections,
each section goes to its own member — nobody doubled up while another sits
idle. The writer's ops grammar drops the presenter field for every type
(unrepresentable beats scrubbed), the distribution is force-applied after
generation, and the chrome footer skips the presenter line on divider surfaces
and on references/bibliography/data-source slides (REFERENCE_TYPES in
src/ai/team.js — a source map is nobody's slide, so it is also spared the
density sweep). The briefing's slides-per-member answer overrides the
automatic per-member target (still contiguous) and reaches generation
structurally through meta.yaml; solo mode ignores it and sizes by parts plus
slide count. Chat turns can still reassign a presenter by hand — their grammar
keeps the field.
The deck carries its own source map. research/pages.json keeps every
source's full text beside the merged notes.md (same bytes the writer drew
from, per-page attribution the merge forgets). After presenters are assigned,
finalizeDeck maps each content slide's grounded claims back to the pages
that ground them (src/ai/provenance.js — numeric claims only, so a shared
name cannot false-link two sources) and inserts one references slide
second-to-last: each source with the slides that drew on it, capped at ten
220-char items. A deck that already carries a references-family slide keeps
it; a deck with nothing mappable gets no slide rather than an empty one.
src/ai/report.js is the report's half of the generation pipeline, mirroring
the deck writer's decomposition (generate.js): a plan call fixes the title
and which of the template's sections carry content, then one call per section
constrained to that section's own grammar. Depth is a parameter, not a second
generator — the same function with different schema bounds and a different
prompt:
- full — three-to-six paragraphs (target four) and a table where one earns its place;
- brief — a headline statement plus three short supporting sentences, never a table, and References capped at six entries.
Both densities write the same decks/<slug>/report.yaml shape and draw
unchanged through the renderer. The depth used is remembered in meta.yaml
(reportDepth) so a later --generate keeps the same budget.
Sections the topic earns. sanitizeReportPlan used to match every planned
name against the fixed eight and drop whatever did not match, so a section a
particular topic genuinely earned was unrepresentable however well the plan
argued for it. The planner is handed the structure the donor declares, may add
up to MAX_CUSTOM_SECTIONS (3) of its own, and those survive coercion.
Position is decided there because it cannot be recovered afterwards — sorting
by structure index says nothing about where a section that HAS no index
belongs. A custom section is anchored to the last structural section listed
before it, and the anchor is clamped away from the final structural position,
so a plan that puts an appendix after the References cannot produce a report
that fails on its own format.
Every section knows what the others are for. Each write call carries the
plan outline (the other sections and their focus) and the opening line of each
section already written — only prose that passed its own grammar, so a dropped
section is not fed forward as something to avoid repeating. The system prompt
had said "do not repeat wording already used in another section" while showing
the model no other section: the running report's content was built empty and
never filled, so only the title ever reached the message.
Shared research orchestration. The report generator reads the same
research/notes.md, the same approved plan.yaml and the same merged identity
the deck used — the outline gate is the human gate for both artefacts. A deck
built with research-source none has no notes file; rather than refuse, the
report briefs from deck.yaml slides (deckFallbackText), so a finished deck
can always grow its companion report with no new research pass. The model only ever sees a bounded excerpt of the
research (excerptResearch in src/ai/research.js); the full artefact stays
whole on disk, because a research pass can outgrow the model's context window
and an unbounded prompt collapses generation. Reachable as
forge report <slug> --generate [--depth full|brief] (CLI) and
POST /api/decks/:slug/report/generate (API, SSE like deck generation).
Two extra doors on the same generator. A report does not have to come from
a deck. forge report-new <brief> / POST /api/reports runs the standalone
flow — brief → research → report.yaml → .docx, no deck.yaml at all. The
template's section order is the structure, so no outline gate applies; the
generator is told requirePlan: false and derives its plan from the brief and
research. And the flow reverses cleanly: forge deck-from-report <slug> /
POST /api/decks/:slug/report/deck plans a companion deck from an existing
report.yaml and its shared research, routing the plan through the ordinary
outline gate and /generate path. That direction reads meta.yaml BEFORE
planning — its maxSlides, slidesPerMember and briefing reach planDeck,
because deck length is the one setting nothing downstream can repair — and
builds its brief from presentSections, so a topic-specific section's prose
is not the material silently missing from the companion deck. Both directions share decks/<slug>/research/,
so the deck and report agree by construction whichever way they were built.
A report-only deck (meta.yaml + report.yaml, no deck.yaml) appears in the
deck list with deck: false and opens the Report view.
src/report.js is the opposite of the deck renderer. No themes, no layout
vocabulary — the whole job is matching the institutional template exactly,
because that is what is graded. The template's own .docx (gitignored
reference/) is the donor:
- Load it with jszip (the
docxpackage is generation-only and cannot open an existing file). - Take
word/document.xmlapart by string surgery: keep the<w:document>opening tag and the body-level<w:sectPr>verbatim, discard the body between them. The sectPr carries the header/footer references and the page geometry, so it is the whole chrome in one element. - Build a fresh body from schema-validated
decks/<slug>/report.yamlplus the merged identity (meta.yaml over config, the same merge the deck uses): a cover page, a table of contents, and the report's sections in the order its ownorderfield gives — falling back to the graded eight (Abstract → Acknowledgement → Introduction → Theoretical Background → Application → Future Scope → Conclusion → References) for a report that predates the field. Sections with no content are skipped gracefully; present sections are numbered 1..N. - Rezip. Every part other than the body survives byte-for-byte, which is what preserves the VML watermark and banner inside the headers, the footer's page field, the styles the generated tables reference, and all media.
The TOC's page numbers are real, not hand-typed: the render is two-pass. The
first pass renders to a temp .docx, libreofficeToPdf (a helper shared with
src/preview.js) converts it, pdftotext locates the page each numbered heading
lands on, and the final pass writes those numbers. The first section starts on
a fresh page like the donor's Abstract. Exactly ONE page break follows the
cover and ONE follows the TOC, and section 1 needs no pageBreakBefore of its
own — the doubled breaks the renderer used to emit produced blank pages 2-3
whenever a cover or TOC ended near a page boundary, and the single-break layout
is verified blank-page-free by rasterising a real report.
Content shape (schema/report.schema.json): a section is paragraphs plus an
optional borderless table (bold header, like the donor's own content table);
References additionally accepts entries. The renderer is depth-agnostic, so
both report densities — full and brief — draw through it without knowing which
they are.
The section LIST is data, not a constant. buildBody emits content[name]
for any name and always has — which is how the Image Credits appendix numbers
and paginates without being in the graded eight. What was fixed was the list,
and that belongs to whichever institution's template is installed.
parseDonorSections reads the donor's own numbered heading run (bold,
top-level, consecutive from 1) and reportStructureForDeck hands it to the
planner, cached per file mtime+size so a re-uploaded template is re-read. Bold
is the discriminator rather than numbering: a report's reference list is the
other consecutively numbered thing in the document and also starts at 1. Tables
are skipped so the template's own TOC is not read as a second copy of the
structure. A donor that declares nothing recognisable yields null and the
graded eight stand.
Because a report may now carry a section the graded list has never heard of,
its order stops being recoverable from the section names and is recorded in the
artefact as order. A section carrying content but absent from order is
still emitted — losing model-written prose to a bookkeeping slip produces a
.docx that renders and looks perfectly fine.
Reachable like the deck path: forge report <slug> (CLI), and on the API
GET/PUT /api/decks/:slug/report plus POST /api/decks/:slug/report/render
returning the download URL — the server stays a transport. A missing donor
(reference/ empty or ambiguous) fails loudly rather than half-working.
src/ is the whole implementation. app/server/ is a transport over it and
holds no logic; the CLI must be able to do everything the UI can, because the
pipeline has to run headless.
The orchestrator (src/ai/pipeline.js) is where the CLI and the API meet:
createDeck (brief → research → outline) and generateFromPlan (outline →
deck → render → rasterise) are shared, and the forge CLI plus the server's
SSE endpoints are thin wrappers. src/ai/pipeline-cli.js owns argument parsing,
terminal formatting and process exits; importing the pipeline never imports the
command adapter. Long-running calls stream Server-Sent Events
over a POST body; dropping the socket aborts the request via an
AbortController, so a vanished client stops the model call.
createDeck freezes the briefing into meta.yaml — team, guide, academic,
theme, max-slides, slides-per-member and now density — so the deck detail
can show what the deck actually is, and generateFromPlan re-loads that meta
so the per-member promise survives from plan to deck. The outline itself is
sized in planDeck: the max-slides budget counts content slides only, with
the title, section dividers and closing slide as structural overhead that
never counts toward it; a slidesPerMember briefing sizes the plan to the team
(N members × M each, capped by max-slides) and trimContentToBudget trims it
to that exact number. The budget is a two-way door: mintContentSlides GROWS a
thin outline (fewer sections than people, one slide per part) up to the
team-sized count before distribution, so a plan can never strand a presenting
member — the observed defect was 11 members and 10 content slides, the deck
jumping from the 10th member's slide to the closing. generateDeck then writes
one validated slide per plan entry, and the writer loop treats "no new valid
slide appeared" as a failure — an empty or mis-targeted op list would otherwise
apply as a no-op and silently vanish, so it retries once and then writes a
validating placeholder (placeholderFor): a degraded slide beats a missing
one, and a divider spec never degrades into a content slide. The writer's ops
are read tolerantly through slideFromOps (src/ai/ops.js): a model that
drifts toward update_slide without an index, or a full slide object on the
wrong key, is a correct-in-intent rewrite, not a failure to discard — the sweep,
convert and field-length rewrites all resolve through it.
A deck has a lifecycle that is also a disk boundary: planning means
meta.yaml + plan.yaml exist and no deck.yaml does, which is what makes the
outline gate enforceable — nothing renders until a human approves.
Generation is split into a write half and a finalize half, each standalone so a dropped run re-enters only the stage that is left:
writeDeckContentpersists the approved outline toplan.yamlfirst (the resume contract), then checkpointsdeck.yamlas each slide lands (theonSlidehook ongenerateDeck), writes a durable.run.jsonmarker and setsmeta.statusto "writing". A dropped connection leaves a resumable deck, never a lost run.finalizeDeckruns the post-write pass — grounding, the field-length rewrite, trim, coherence, render, preview — and flipsmeta.statusto "ready", clearing the checkpoint. It reads deck.yaml + plan.yaml from disk, so it can finalise a deck whose content is complete but whose run died before the pass (the "N/M written — finalize it" watchdog state).
The server no longer aborts a run when the SSE socket drops; that was the
"refresh restarted from slide 1" bug. Runs live in a slug-keyed in-memory
registry (generationRuns in app/server/index.js), a reconnect attaches to
the SAME run and streams its events (dedupe, not restart), and Stop is the one
explicit abort via POST /api/decks/:slug/generate/stop — the checkpoint
survives even that. GET /api/decks/:slug folds the registry over the on-disk
checkpoint into a run field ({active, written, total, resumable, needsFinalize}), which is what lets the chat and deck views offer watch /
resume / finalize instead of a silent fresh run. The CLI mirrors the split:
generate --resume and a finalize command.
src/ai/fieldlength.js runs after writing and after a density sweep, BEFORE
the deterministic trim. It renders to find slides the fitter flags below their
floor, walks each flagged slide's string fields against their per-field schema
caps (fieldInventory), and sends one scoped model call per slide: rewrite the
flagged fields as complete sentences within their caps — no ellipsis. Only what
it cannot fix reaches the trim, and shortenString (src/ai/trim.js) now cuts
at sentence boundaries only: with no sentence end reachable it returns null and
the floor flag reports the slide rather than ship a mid-sentence "…". The two
together make the banned artifact unproducible.
A chart slide with empty values is banned at every entry point: the
data-affinity steering tells the planner and the writer to choose chart only
when the research carries real numbers (and names the qualitative alternative
when it does not), planDeck coerces a proposed chart to cards below the
numeric-fact threshold, and the schema requires series[].values and
categories to be non-empty — so an empty chart fails validation and the
render gate refuses it.
The chat panel is the conversational surface of the same generation pipeline,
not a second one. src/ai/chat.js orchestrates one turn exactly as the vision
critic does — the instruction is human-typed instead of vision-derived — and
both call runTurn (src/ai/turn.js). The look-ahead seam is enforced in code:
runChatTurn requires deck.yaml to exist, because turn = edit an existing
deck and generate = build from empty are deliberately different paths.
The input bar knows WHICH slides you mean. Once a deck is ready a ~40% side
panel sits beside the thread (the chat shifts left) showing every rendered
slide scrollable (SlideSelectPanel.jsx). Selecting one or many and typing
builds the turn context from the selection — each chosen slide's 1-based index,
type, headline and content excerpt — so "add more content to THIS slide" can no
longer drift. The deck view's lightbox writes the focused slide into a
module-level deckContext store, so opening slide 4 there and returning to the
chat makes "make THIS punchier" resolve to that slide. The panel also carries
per-slide actions (punch-up, type swap) over the selected set, through the same
endpoints the deck view's toolbar uses. A leading / in the input is parsed
before the turn runs (lib/slash.js): /theme, /density, /slides, /papers,
/report and /help map to direct actions with an inline confirmation, and an
unknown command gets a helpful reply instead of being forwarded as a prompt.
The composer is an auto-growing textarea (44px up to a ~152px cap, then
scrolls).
The briefing is two forms, not fifteen cards. Every question in
app/web/src/lib/briefing.js carries a tier. required — theme, length,
density and the research choice for a deck; depth, density, research for a
report — renders as one card with a single action. optional is everything
else behind one collapsed panel that skips in a click or opens to all ten
fields at once. It was a walk of fifteen questions, and a user who wanted none
of them still clicked through all fifteen; the count was never the defect, the
walk was. tierQuestions() also drops the preset field when the account has no
presets, and whatever a chosen preset already fixes.
Each field is the same card the walk used, in embedded mode: it patches the
briefing as it is touched and renders no footer, because the form owns the
action for the tier. Text commits on blur rather than per keystroke — the
briefing persists to localStorage on change. briefTier(kind, step) maps the
stored briefStep onto a tier, so threads written before the tiers still open.
The briefing reaches the planner verbatim. The full briefing record — team, guide, academic, audience, emphasis, theme, density, branding, slide count — is sent as an explicit "The user answered: …" block alongside the research brief, and the planner is told to always produce a complete talk (title → intro → body → conclusion/closing) regardless of what the brief says.
Memory model — state over transcript. The thread is three tiers:
| Tier | Holds | Lives in |
|---|---|---|
| State | current deck.yaml + theme voice + meta.yaml |
the files themselves |
| Recent | last 10 turns verbatim | decks/<slug>/chat.jsonl |
| Durable | extracted standing preferences | decks/<slug>/decisions.md |
deck.yaml is the memory: after a turn the result is in the file the model
reads every turn, so the transcript only carries intent that has not yet been
materialised. chat.jsonl is one JSON object per line: a summary record
followed by user/assistant pairs. Beyond the 10-turn window, the oldest turns
collapse into the rolling summary (a utility-role call) instead of being
dropped, so a long session degrades gracefully. Durable preferences are
extracted each turn by the utility role with a tiny bounded schema — a
one-off request is never promoted — and promoted instructions leave the replay
window because they already sit in decisions.md, which runTurn feeds as
system context.
The server's /api/decks/:slug/chat endpoint is SSE over a POST body, the same
transport generation uses: token frames stream the model's working, status
frames report reading/editing/rendering, and the result frame carries the
applied changes, the diff, token stats and the re-rendered previews. A dropped
socket aborts the turn via the shared AbortController, exactly like
generation. The forge chat <slug> "<instruction>" CLI command is the same
orchestrator headless.
Inline editing is a separate, model-free surface. SlideEditor.jsx edits
one slide's content through the same validated PUT /api/decks/:slug and
re-renders; it shares no code with the turn path. The two layers are
deliberate: chat is structural, inline is precision. Every mutation the deck
detail performs — edit, presenter assign, reorder, delete, duplicate — goes
through the immutable ops in app/web/src/lib/slides.js, persists deck.yaml
immediately, and re-renders on a short debounce.
#/admin (app/web/src/views/Admin.jsx) is the operator surface, gated by src/auth.js:isAdmin — role=admin in users table, or email==18deepnar@gmail.com (hardcoded seed, auto-promoted on register/findOrCreateGoogleUser), or FORGE_ADMIN_EMAIL env. App.jsx:139 isAdminUser controls the Sidebar shield link (Sidebar.jsx:287) and App.jsx:244 #/admin route; the view itself also handles 403 from the API.
The HTTP server is composed in app/server/index.js, which keeps route order
explicit. app/server/auth-routes.js owns authentication and throttling,
account-routes.js owns provider keys, budgets and usage, lifecycle.js owns
public system routes, maintenance jobs, static UI hosting and boot diagnostics,
workspace-settings-routes.js owns presets, per-account identity and brand
assets, and account/operator report-template uploads, while http.js owns the
response wrapper. Business behavior remains in src/; these modules only
translate HTTP requests and register routes.
admin-routes.js owns administrative account, plan, quota, cleanup, storage
and health endpoints. It receives deck metadata and directory-size readers from
the composition root rather than reaching back into the entry point.
artifact-routes.js owns deck files, render/preview, exports, research and
reports. generation-routes.js owns specimens, chat, creation and resumable
generation, including the in-memory live-run registry. Shared SSE, render-queue
and metering guards are injected by the composition root.
The admin routes guard GET /api/admin/hosted / POST /api/admin/hosted
(src/cloud.js:isHosted/setHosted → config/hosted.json, file wins over env
for runtime flips), user role/deletion operations, deck inventory and aggregate
statistics. published/ (published/*.pptx|*.pdf|*.docx) is the public
showcase, force-added via !published/** in .gitignore:28 while private
decks/* stays ignored except !decks/_public/**.
Frontend Admin.jsx has five tabs — Overview (4 stat cards + BarChart for themes/owners + recent decks), Users (search, Make/Remove admin, Delete), Decks (search, all decks table), Analytics (total requests/slides/tokens, top users bar chart, limits grid), System (hosted/local switch with forge:hostedChanged + reload, backend health, disk/uptime, RBAC explanation). SettingsModal.jsx:522 HostedSection (admin only) also toggles hosted and reloads, so Settings and Chat (ChatView.jsx:1015 hosted/local badge + 944 Local·Ollama/Auto·TCET pill) stay linked.
published/ holds three complete visually verified examples: mixed-mode HPC,
first impressions and perovskite solar cells. Each example includes a
GitHub-viewable presentation PDF, editable PPTX, companion DOCX report and
Markdown speaker script; published/previews/ supplies the README thumbnails.
The directory is force-added through !published/** while private decks/*
remains ignored. The README presents these outputs immediately after the product
badges, and published/README.md records their provenance.
Theme selection is persisted in deck content. writeDeckContent() resolves an
explicit override first, then a resumed deck's theme, then meta.yaml; finalizing
uses the same precedence and writes the result onto deck.yaml. This prevents a
separate generate command from silently replacing a briefing choice with the
default theme.
FORGE_TCET_API_KEY in .env.example is now sk-change-me-tcet-key placeholder, config/hosted.json is gitignored (config/hosted.json), config/local.yaml/users.json/sessions.json/forge.db stay gitignored. git ls-files shows no real sk- keys; config/local.yaml on disk holds the real OPENCODE_GO_API_KEY but is ignored. Chat normalization (app/web/src/lib/chats.js:12 normalizeChat, briefing.js:222 echoAnswer guard) and ErrorBoundary.jsx:1 + SlideSelectPanel.jsx:52 PanelLeftClose fix prevent the white-screen on existing chats; favicon regenerated from logo.svg (app/web/public/favicon.png 32 + apple-touch-icon.png 180).
src/ai/grounding.js runs after deck generation in generateFromPlan (and
again on the critic's output when the critic rewrites the deck). It walks every
slide's content, extracts the factual claims — numbers with units, years,
proper-noun phrases — and checks each against the research text the writer was
given. Anything it cannot find is appended to the slide's notes field as a
[grounding] line and surfaced in the render result's problems[]. It never
edits a number to make the check pass; flagging is the whole job.
The guard is deliberately conservative about what counts as a claim. Headlines and standfirsts are the rhetorical frame, so their noun phrases are skipped (their numbers still count). Names are only extracted from fields that carry a real figure — a name tied to a number is a claim ("Inflation Reduction Act unlocked 180 GW"), a bare card title or stat label ("Early Innovator") is not. Without any research text the pass stays silent: flagging every number against nothing would be noise, not honesty.
Because the research the writer sees is research/notes.md, and that file is
now user-editable from the deck detail's Research panel, the grounding contract
is: the notes are the ground truth, the user owns them, and the model is
flagged when it leaves them.
Numbers are claims too. The walker treats numeric values like any other
figure, so a chart series value or journey value the research never states is
flagged exactly like a fabricated percentage — the "are the graphs reflective
of real numbers?" check the user wants before presenting, not after. The one
structural exception is depends_on: integer indices that reference other
nodes, not measurements. deckFigures surfaces the same numbers to the
Research view as plain mono chips against the source table, so the cross-check
has a surface, not just a rule.
Geometry is honest about what it measures. A shape that looks like a
measurement only draws it when the data carries that measurement: the journey
renders a real line only when every stage carries a numeric value (any
unvalued stage switches the whole slide to a flat milestone rail, sentiment
colouring nodes categorically and never positioning them), and the funnel's
taper comes from real numbers only when every stage has one, otherwise
equal-width steps. Qualitative types keep their designed, clearly-illustrative
treatments — the rule is that a decoration must not pretend to be an axis.
The research pass is deep, not one query. deepResearch (src/ai/research.js)
expands the brief into 5-8 angle queries via the research role (keywords,
science, practical, regional, data, counterpoint; falling back to the brief
alone when the model is unavailable), runs each at a higher read budget than
the old single query, follows up the richest sources, then closes the gaps the
diversity guard finds (missing domains / missing academic voice) and the
examiner-gap pass raises. The model only ever sees a bounded excerpt; the
richer artefact stays whole on disk for the deck, the report and the user's
edits. Papers are a toggle away. src/papers.js queries arXiv and
Crossref for the brief, dedupes by canonical id, ranks by brief-term overlap,
writes arXiv IDs and DOIs into sources.json with kind: "paper", and pulls the
top papers' full text (arXiv HTML) into the notes. Jina Reader
(https://r.jina.ai/<url>) is the second extraction path for JS-heavy pages,
gated behind RESEARCH_JINA=1 so the local-first default is untouched.
A source has to be about the topic, and nothing used to check. absorb
filtered on fetch success, URL dedupe and a per-host cap, so a real run for a
solid-state battery deck took in Wikipedia's "India" (31,107 words) because one
angle query said "India", four Microsoft support pages, a Minecraft mod and
drugs.com on lithium the medication — five of twenty-one sources were about
batteries. topicalDensity now scores each page against terms derived from the
brief (topicTerms, so the code stays topic-agnostic) and RELEVANCE_FLOOR
refuses the rest.
It measures density, not presence, because presence cannot see the failure: the India article contains five of the brief's seven terms — every "mentions the topic" rule keeps it — at 0.4 hits per 1000 words against 35.6 for a real battery article. A hyphenated compound is kept whole and never also split, since "solid-state" split into "solid" and "state" puts an ordinary word in the yardstick and collapses the margin. Off-topic pages are recorded but never backfilled: a thin corpus is a worse deck, and a corpus about the wrong subject is a deck about the wrong subject.
Invented images degrade, never crash. The renderer's resolveAsset returns
null for URLs and for files that do not exist, and every image layout already
draws a placeholder for a null source — so a model that puts
https://example.com/x.jpg in an image field costs one grey box, not a whole
deck that fails at write time. Both the planner and the writer are told to avoid
image-requiring types unless the brief names real files.
The briefing's research question is a three-way choice — Web search (default) |
My uploaded file (no search) | No research — and the upload path is a new
intake seam, not a fork in the pipeline. When the file wins, the research pass
is skipped entirely (no SearXNG, no arXiv/Crossref, no Jina) and the document
becomes research/notes.md verbatim, marked kind: "user-provided" in
sources.json, so everything downstream — planning, writing, the report, and
the grounding guard — works on exactly the text the user gave.
src/ai/upload.js owns the intake:
- Conversion. md/txt are decoded directly (with a binary sniff so an
executable renamed
.txtfails loudly); docx/pdf go through LibreOffice headless to text (the same private-profile + absolute-path discipline as the preview rasteriser). Caps: 25 MB and 60k words, rejected before staging. - Staging. The converted document is staged under a token in gitignored
config/uploads/(swept after 48h), so a large file never round-trips through the browser or localStorage — the briefing holds{token, name, words}and the plan resolves it once the deck's slug exists. The CLI (forge new/report-new --upload <file>) passes{name, text}inline instead;resolveResearchSourceinsrc/ai/pipeline.jsnormalises both. - Server.
POST /api/briefing/upload(session-gated like presets) accepts md/txt/markdown/docx/pdf raw bytes, validates extension + size + readability, stages, and returns the token; the plan'screateDeck/createReportacceptresearchSource+upload.
The grounding guard becomes strict fidelity automatically: its label names
"your uploaded file" instead of "research/notes.md" when meta.researchSource
is upload, so the speaker's notes state the contract. The upload is visible and
editable in the existing Research panel before generating — notes.md was always
user-editable, and in upload mode it IS the user's document.
Grounding proves the facts; coherence proves the argument. A slide can be perfectly grounded and still not belong — the soft-skills deck shipped a flight-attendant demographics chart inside a first-impressions deck, real data with no link to the deck's point, and a presenter could not voice the "so what".
src/ai/coherence.js is the post-generation review that catches this. After
generation (generateFromPlan) and after a density sweep (sweepDensity) it:
- Reviews the finished deck against its title and sections with one bounded call (the critic's small-schema lesson): each slide is flagged for either topical drift (headline and content do not serve the deck's central topic or this section) or framing (data without a point — no implied "so what" a presenter can voice).
- Rewrites every flagged slide through
runTurnwith a per-slide fix instruction naming the exact reframe, capped at two rounds like the critic. - Re-grounds and re-trims the fixed deck, so a fix never trades a coherence problem for an ungrounded claim or an overfull slide.
The writer prompts carry the framing rule as a first line of defence: the headline is a claim, the body is its support, and a grounded fact that does not serve THIS deck's argument is not used — even if it is in the research. The report section writer carries the same rule, and the report planner must agree with the deck's approved outline, so both artefacts stay coherent with the same topic.
The coherence pass asks "is there a 'so what' the presenter can voice"; the
speaker script is where that voice lives. src/ai/script.js writes
decks/<slug>/script.md, the words each presenter says aloud for every slide:
60–90 seconds of spoken prose per content slide, in the presenter's voice,
bridging the slide's bullets to the deck's argument and voicing the connection
the slide only points at, grounded in the same research/notes.md the deck was
written from. It rides the same decomposition as every other writer — one
small-grammar call per slide (a single words string capped at 1999 chars) —
and is deliberately a button, never automatic: the user asked for it "at the
end if I want", so Export's "Speaker script" item and the deck-detail Script
panel both generate on demand, and a per-slide Regenerate rewrites just that
slide's words after an edit.
script.md is plain markdown with an invisible <!-- slide:N --> marker per
block. A regeneration replaces exactly its own block and rebuilds the file from
the deck's slide order, so a deleted slide drops its block and unwritten slides
stay blockless (the panel shows them as "not written yet" with a regenerate
affordance, never placeholder text). The API mirrors the sweep (SSE, abort-on-
disconnect): POST /api/decks/:slug/script with an optional index, plus
GET returning per-slide blocks; the CLI is forge script <slug> [--slide N];
and the existing title-naming download route gained a deck-root fallback so
script.md (a content artefact living beside deck.yaml, bundled in the .zip)
downloads like any other file.
When a slide's generation fails (cut-short JSON, a model that never lands a slide), the writer writes a validating placeholder so the deck stays whole and the promised count holds. The placeholder used to read like real content — "Details in the full briefing." sailed into committed decks.
src/placeholders.js is the single seam that recognises placeholders by their
marker text (current and historical phrasing), and three layers make them
impossible to miss:
- The render gate. A real render (
write: true) of a deck with any placeholder slide throws, returning a 422 that names every placeholder slide. The trim loop and preview pass render in-memory so audits still run; only shipping is refused. - The problems list.
generateDeckgives each surviving placeholder one dedicated regeneration attempt after the write loop, then flags any that remain intoproblems[]("slide N (bullets) is a PLACEHOLDER — it must be regenerated before this deck is presented"). - The deck view. A red "Needs regeneration" panel lists every placeholder slide with a per-slide Regenerate button (a scoped chat turn that rewrites the slide from research), plus a badge on each affected slide card.
The three-layer rule says the model never writes layout. It also never writes a
URL, and images are where that is hardest to hold: a writer asked for a picture
will happily produce https://example.com/x.jpg. The seam that resolves this is
a description, not an address. When a slide wants a picture the writer emits
[image] <description> into notes, and the sanitisers in src/ai/generate.js
rewrite any URL it invents into that same shape. Everything downstream works
from the description.
src/ai/images.js closes the loop, opt-in per deck via meta.imageSupply
(asked in the briefing beside research, --images on the CLI). It runs in
finalizeDeck after the coherence pass.
Sources are chosen by one property: they state the licence themselves. An
image whose rights cannot be established is unusable in an academic submission,
which rules out the obvious candidates — SearXNG normalises the licence out of
every image row (including its own openverse engine), and the Unsplash Source
API is retired. So:
- Wikimedia Commons, primary. Reports
LicenseandAttributionRequiredper file, has no punitive rate limit, and is deep in technical figures where photo aggregators are empty.iiurlwidthrasterises SVG server-side, so a vector diagram arrives as a PNG the renderer can place. - Openverse, secondary, for photographic breadth. Anonymous budget is
20/min and 200/day, enforced by
makeBudgetand re-read from the response'sx-ratelimit-available-anon_sustained.OPENVERSE_API_TOKENraises it.
Licences rank, they do not filter. licenceTier maps both upstreams'
vocabularies onto: 0 public domain and CC0 (nothing owed), 1 CC BY, 2 CC BY-SA.
NonCommercial, NoDerivatives and unreadable licences are refused outright.
Ranking rather than restricting is what gives the widest pool without silent
legal debt — a public-domain image is taken when one exists, and a CC BY one is
taken rather than leaving the slide bare, with the credit written down either
way. assets/auto/credits.json is the machine record the cache reads back from;
CREDITS.md is the page a person can paste into an appendix.
The credit has to reach a reader, or the licence ladder bought nothing.
src/credits.js is the shared vocabulary — it lives in src/ rather than beside
the supply because src/ai/report.js already imports src/report.js, and a
renderer reaching back into the model layer would close that loop. It is read by
four surfaces, and they are not alternatives:
| Seat | What it carries | Why it is not enough alone |
|---|---|---|
Deck page (DeckDetail) |
every credit, short form | lives in the app |
Research view (ResearchView) |
every credit, full table, with the report's omissions marked | lives in the app |
Report (src/report.js) |
an Image Credits appendix, citable sources only |
nothing if the deck is presented without its report |
The deck (attribution slide) |
only what owes attribution | — the only copy that survives the .pptx leaving the app |
isCitable splits provenance a report can name from a stock photograph.
Openverse aggregates 52 providers and 42 are museums, national libraries,
archives, government agencies and scientific registers, so the denylist of
consumer upload platforms is the short and stable half. It fails open on
purpose: an allowlist would drop a legitimate museum the day one is added, and a
missing credit is worse than a generous one. The named cost is flickr, which
carries real government photostreams the provider name cannot distinguish — those
are excluded from the report and still shown in the app.
The report's appendix rides present rather than joining REPORT_SECTIONS:
that list is the structure the planner writes prose into, and asking a model to
author the provenance of files it never saw is how invented citations get in.
Riding present is all the TOC row and the two-pass page locator need. The deck
slide is appended after presenter assignment — back matter takes no presenter —
and outside maxSlides, because a credit the licence requires is not content
competing for a slot.
Queries widen; they are never passed through. Both upstreams AND their
terms, so a written description returns nothing ("electrolysis cell diagram"
→ 0, "electrolysis" → 240). queryLadder steps from the full phrase, to the
phrase minus the picture-kind words (diagram, photo, schematic — generic
vocabulary, never topic knowledge), to the two most distinctive terms. It stops
at the first rung that lands, and it stops at two terms, never one: a
single-word query has lost the subject, and measured against the live upstreams
it fetched the planet Jupiter for a V2G bus depot, an 1898 lithograph of human
anatomy for anatomy, an abandoned bus in the Atacama for bus, and a musical
notation mark for the product name fermata.
And the result is checked against the request, which nothing used to do.
rankCandidates orders by licence tier and pixel width, so before this a
correctly-licensed 4000px picture of the wrong thing won every time.
rankBySubject makes topical relevance the primary sort key with a floor: a
candidate must match a majority of the description's terms — at least two,
never more than three, because a description carrying a product name and a
model number can never be matched four ways by an archive photograph. Terms are
matched on a trimmed stem so buses finds bus. An untitled candidate cannot
be judged and is refused.
The errors here are not symmetric, and that is what sets the threshold. A wrong picture ships on a submitted deck; a refused one leaves a seat that renders as the ordinary list the slide already is. A low fill rate is the intended outcome, not a failure.
Seating is lossless or it does not happen, and this is the part the schema
alone cannot decide. image, image-text, hero-image and image-grid all
require their image field, so a validated deck never contains one waiting to
be filled. That was once the whole problem: measured across 73 types, exactly
one (testimonial) declared an optional image, so [image] notes landed
almost entirely on types that could not carry a picture at all — on one real
deck, the single note in 22 slides was on the TITLE slide.
illustrated-points exists to be the seat. Three to six points and an OPTIONAL
image: the writer produces it empty and the slide is finished, rendering as an
ordinary full-width list; a supply fills the same slide later and the points
move to a column. Neither state is a degraded version of the other, and the
layout branches on whether an image was asked for rather than whether one
resolved — collapsing those dropped caption silently whenever an asset was
missing, a credit vanishing with the picture it credits.
Three ways in:
- An optional seat (
illustrated-points,testimonial) is filled in place.imageSeatderives these from the schema, so a new image-carrying type is seated the day it is added. - Otherwise the slide is promoted to
image-text— the same conversionDeckDetailperforms on a manual upload — but only when its list fits that type's four-line body. This one runs without a human watching, so it may not drop a point the writer meant to make. - An EMPTY SEAT is itself a request. Waiting for the writer to say
[image]does not work: run end to end, the planner seated a beat, the writer produced a good slide, and never wrote the note — so the supply had nothing to look for. What to photograph is asked directly instead, of theutilityrole, as a literal short subject with a minimum length as well as a maximum (told "six words at most" the model answered "bus", and the search obliged with an abandoned bus in a desert). A headline cannot serve: it is written to be read, and "The Physical Anatomy of a V2G Bus Depot" searched as a picture returns human anatomy.
Which beats get a seat is decided in the plan, not asked for. Across seven
planning samples and three promptings a local qwen3.6 chose illustrated-points
zero times; told to "include two or three" it complied and the deck's variety
collapsed from 17 distinct types to 10. So seatIllustratedBeats assigns them
after the outline returns, the way presenters and the structure contract already
are: list-shaped beats only, one per section, three per deck, and none at all
when nothing in the deck looks showable.
Then two gates, because "lossless" means the words survive on the page:
- Schema. The errors on the touched slide before and after. Not whole-deck
validity: real generated decks arrive slightly invalid (
loadDeckwarns on an over-long field rather than refusing), and a global gate reads those as this slide's fault. - Fit.
image-textdrawsbodyin half the width a full-bleed list gets, so four comfortable bullets can overflow with every cap check green. The supply sweeps its own result overCOVERING_THEMESand gives the picture back on any slide that does not fit cleanly — the covering set rather than the deck's own theme for the reasontrimDeckToFitsweeps every theme: the theme is switchable afterwards.
A slide that fails any of this keeps its [image] note, so the deck page's
"add image" prompt still offers the manual door, and the reason lands in
problems[]. Manual upload always wins: it targets the slide directly and
leaves no note for this pass to find.
The browser UI is a shell around the same src/ pipeline; app/server stays a
thin transport and every control maps to a real endpoint. App.jsx owns the
deck list (fetched once, re-fetched on a version bump) and routes between
chat, deck, report, research, themes and home. View
routing is hash-based (lib/router.js): every navigation pushes a
#/chat[/<id>], #/deck/<slug>, #/report/<slug>, #/research/<slug>,
#/themes or #/home entry, and a single hashchange listener
is the only consumer of the URL — so the browser back button walks the same
route the user walked forward (chat → deck → home) instead of exiting the
site, and a deep link like #/deck/<slug> reopens that view on reload (per-user
auth still gates it, and a route's hash survives logout, so logging back in
restores where the user was). Settings is deliberately NOT a route — it is a
modal over whatever view is open (an old #/identity hash resolves to chat).
Route views are loaded through React lazy, so the shell does not download the
chat, deck, admin, legal and tour surfaces at startup. Large surfaces keep their
state machines in the route module and move independently reusable panels into
ChatPanels, DeckDetailControls, AdminPanels and SettingsSections.
SlideEditor reads its schema-shaped descriptors from slideEditorFields.js.
Entry flow — landing-first. The #/home landing page is the front door, now with a pinned scroll tour (GSAP ScrollTrigger pin 2000%, scrub 1.2) and a fixed header that auto-hides on scroll down and reappears at the footer. An unauthenticated visitor lands on it whatever the hash; the landing carries ONE strong CTA — "Start a chat" leads to sign-up — with the rest as quiet links. The AuthModal is split screen. After login the landing is a pending New chat (lazy, not in sidebar until first prompt) — pendingChat in App.jsx holds it, handleChatChanged saves on topic.
HeaderBar/LandingHeader—fixedonisTourView(home,tour-themes, legal) with auto-hide on scroll down (reappear on up or footerIntersectionObserver),stickyelsewhere. Wordmarkhref="#/home"vs"#/chat",AUTO/CLOUDtoggle hidden on tour (now inSidebartop bar andChatViewcomposer), GitHub link, auth buttons.isTourViewdrivespt-14offset andParticleFieldboost. Forview==="chat"the global header is hidden andChatView's own header carries the left-navPanelLeftand slides-panel toggle.Home— the landing tour at#/home, the front door. For a visitor it adds a slim auth bar (a single "Log in" link — the hero's one strong CTA is "Start a chat", which routes to register) and the tour; for a signed-in user the same page is the tour. Hero with the "the app does the bulk, you do the final touches" framing and "Bulk by machine, polish by you", the pipeline as a designed icon flow, how-it-works cards, a live theme carousel (reusing the specimen renderer), a local-vs-cloud split, a capability grid and a footer strip. Sections scroll-reveal on the shell keyframe via an IntersectionObserver; reduced motion shows them immediately.ParticleField— an ambient canvas dot-field behind the whole shell: a fine stipple drifts on two-axis sine wander so it moves without the pointer, and repels gently around it. Tuned as "drifting embers" — base opacity 0.16–0.40, accent-dot share 0.22, 150px repulsion — with a 1.5× opacity boost on the login screen. Capped at 60fps, one static frame underprefers-reduced-motion, no loop while the tab is hidden or a navigation rail is focused, and nothing at all when a 2d context is unavailable.App.jsx— the chat store and its cross-tab sync. Chats live in localStorage keyed by account (lib/chats.js), persisted on every change and loaded at boot. Because a middle-clicked tab is a fresh load over the same storage, the shell listens for thestorageevent on its chat key: any other tab that writes the list triggers a reload here (the active chat survives unless the other tab deleted it, and the deck list refreshes when a produced chat appears). "New chat" (sidebar button, Home, Ctrl+N) returns to the account's existing empty chat of that kind — nothing sent, nothing produced — instead of stacking another empty row, so repeated clicks stay one thread until a topic is actually sent.Sidebar— the per-user navigation. Chats and Decks tabs (the old All decks / Reports split is now the Decks tab,report-flagged rows open the report's full-document view). Search filters title/slug/theme client-side and greps content server-side; the rail collapses to an icon strip and both states persist via localStorage. Every row — chat or deck — is a real<a href="#/chat/<id>">/<a href="#/deck/<slug>">so middle-click and copy-link work, and carries a hover "⋯" popover: a chat deletes locally, a deck opens or deletes server-side (DELETE /api/decks/:slug, behind a confirm modal). Deck names clamp to two lines so long titles stop truncating to initials. The one creation entry app-wide lives here, "+ New chat". The footer's bottom-left is a profile chip (ProfileChip.jsx) — avatar + name, avatar-only in the collapsed rail — that opens the Settings modal (SettingsModal.jsx), the ONE management surface. Three sections: Account (the person, cloud status/key/routing, logout behind the shared confirm), Saved formats (full preset CRUD — create/edit/delete a briefing format without any chat, shared with the briefing via a module-level presets store), and Identity (the long-term facts — institution and guide — edited inline; brand-marks upload kept; the per-submission fields subject/year/semester/ exam-type/team are asked in the briefing and never stored here). The sidebar Settings row opens the same modal.ChatView— the chat window IS the app. A message thread with the input bar at the bottom: send a topic, the assistant walks the guided briefing one question at a time (each with a default), then a summary card whose "Plan the deck" is the only trigger of research and planning; the outline lands back in the thread for approval and "Open the deck" reaches the artefact. A Chat/Report toggle flips the same input bar between products. The input bar's model pill persists the pick on the chat. Runs survive leaving the chat: a generation'sAbortControllerand status live in the module-levellib/runs.jsregistry, never in component state, so navigating away unmounts the view without touching the run — re-entering adopts the live run (busy, live status, working Stop button) and errors are persisted onto the chat so a failure that lands while the user is away is shown on return. After the deck is ready the same thread becomes the deck editor: the briefing cards collapse into a compact "Deck briefing" recap block (expandable to the full Q&A record), the input stays live, and a message there runs a real deck-edit turn through/api/decks/:slug/chat(sendEditTurn), with the applied changes and the fresh slide thumbnails landing back in the thread as a persistent turn log (chat.turns). A produced report chat keeps its readable record instead — input disabled with a hint.DeckDetail— theme/style selects, a Density select + Re-sweep that rewrites every content slide's body at the chosen density (sweepDeck, one scoped call per slide, grounding still applied), render +.pptxdownload, the slide grid with lightbox/inline-editor/presenter ops, a per-slide "make it punchier" bolt, a per-slide type swap opening a gallery of all 75 types rendered in the current theme (specimens insrc/specimens.js, cached per theme; compatible types remap locally, the rest get a scoped rewrite), per-slide image upload todecks/<slug>/assets/that sets the slide to an image type with the real asset, the Report panel (generate when noreport.yamlexists, otherwise render.docx, download, thumbnail strip and an "Open report view" door), and the Research card — a compact coverage line that opens the full Research view. The density select readsmeta.density(persisted at planning, overwritten by a sweep), so the deck reflects the briefing's choice instead of always defaulting to balanced.lib/time.jssupplies the relative timestamps used everywhere.- The lightbox is the same slide with the same actions. The enlarged view carries the full per-slide toolbar (edit, punch-up, swap type, add image, move, duplicate, delete) for the current slide — no feature exists on the small card that vanishes at full size. The swap gallery's 75 tiles render at ~200px+ and add a hover dock plus a click-through full-size preview (the same cached specimen PNG, no re-render) with the commit happening on the enlarged view; the filter box and the current type's "now" badge stay.
- The chat rail is gone; the thread continues instead. The old right-rail
chat on the deck view is deleted, but the deck detail's only whole-deck
changes are not limited to the theme selector and the Density Re-sweep: once
a deck is produced, its originating chat becomes the editor (see
ChatView) and every message is a deck-edit turn. The per-deck thread files (chat.jsonl,decisions.md) keep no standalone UI; the deck page stays full-width. ReportView— the full-document view of a report: a cover block (title, subtitle, subject, guide, team from the merged identity), then every section in the fixed graded order with paragraphs and tables rendered as prose, never YAML. Render.docx/ download, a "Generate companion deck" button that plans from the report's sections and routes through the outline gate (the reverse flow), and — after a render — the rasterised pages of the actual document under "The rendered document" (LibreOffice → PDF → PNG viareportPreviewinsrc/preview.js), so what the user sees is the real Word output. It is the land target of the sidebar's Reports tab and of the home "from a brief" flow; a missing report renders an empty state. Each section also offers Add as slide, which appends the section's prose as a bullets slide of the companion deck — the report-as-deck hybrid (F20). Render .docx and the Download link are gated by the report's write state: a report generation in flight for this slug (started from the deck's Report panel) is mirrored in the module-levellib/reportWrites.jsregistry (slug-keyed, the report counterpart ofruns.js), and the view subscribes — while the write runs, Render .docx / Download / Plan / Add-as-slide disable with the write's status and a Stop affordance, so a report being rewritten can never be double-rendered. The write and the render share ONE busy state, so a "Writing section N of M" can never leave a stuck "Rendering…" behind — the registry'send()fires the terminal patch that clears it. The Download link is derived from server state, not component state: the report GET probes whetherout/report.docxexists, so re-opening the view shows Download immediately without a re-render.- Deck detail actions. The header's action row wires the Part-2 features:
a Dark/Light toggle that remembers
meta.yaml'smode, PDF and Markdown export (src/export.js, reusing the LibreOffice converter for the PDF), a.zipsharing bundle (POST /api/decks/:slug/bundle, jszip over the deck folder), Clone (cloneDeckin the pipeline — content files copied to a fresh slug, meta re-stamped), and a Versions popover over the timestamped backups written intodecks/<slug>/backups/on every save. A "+ Add slide" menu inserts starter slides fromtemplates/*.yaml(GET /api/templates) plus a schema-valid blank of any type (lib/blankSlides.js, derived from the editor descriptors; the editor opens at the new slide). A "Tell it what to add…" input beside it appends one AI-written slide through the existing chat turn. Item rows inside the editor reorder via up/down over the puremoveListItemhelper — semantic order only, no geometry. Ctrl+Z/Ctrl+Y walk a 20-deep deck-state undo stack; Ctrl+S re-renders. - The outline's generation storyboard (F15). While a plan generates, one tile per planned slide appears in the review, the slide being written highlighted as the SSE status frames stream in. This is the light form of the stretch canvas builder: a live filmstrip of rendered slides as they land would need a new SSE phase emitting each validated slide, which remains open.
- Model pickers filter by mode.
lib/modelMode.jsis a tiny client pub/sub holding LOCAL or CLOUD (default LOCAL), rehydrated at boot from the persisted routing preference.lib/useModels.jsfetches the grouped model list once and exposes it filtered to the active mode, so the header toggle takes effect in the prompt, chat and report pickers without each re-fetching. - Motion. Every transition rides one easing
(
cubic-bezier(0.2,0.8,0.2,1),--ease-shell) — view switches (a keyed fade-and-rise), the sidebar collapse, the chat rail, card hover, the carousel — animating transform/opacity only, and aprefers-reduced-motionmedia query collapses them all to instant state changes.
src/usage.js and src/limits.js. The quota began as fair use on the
operator's shared gateway key and is now the thing a price could be attached
to, which required three changes.
The unit. It metered "requests" — one plan, one generate, one chat turn —
while the operator is billed in tokens, so a 24-slide dense deck with deep
research and a 10-slide sparse one both counted as 1. auto_events has always
had a tokens column and every caller wrote 0 into it. The counts were never
missing either: chat() returns promptCount/evalCount for both transports
(usage.prompt_tokens from an OpenAI-compatible gateway,
prompt_eval_count from Ollama) and every caller above discarded them.
src/usage.js is an async-local meter, the same shape and for the same reason
as src/account.js: the model client sits five or six calls below the HTTP
layer, and threading an accumulator through pipeline → generate → turn → chat
would touch the CLI, which has no request to account to. The server attaches a
meter in the same middleware that attaches the account. Outside a metered scope
every call is a no-op. chat wraps chatOnce so the count is taken at the one
point all four of its returns — streaming and non-streaming, two transports —
pass through.
Reserve, then settle. A reservation must be taken BEFORE the work or
concurrent requests all read an unspent budget (reserveAuto is synchronous
from count to insert for exactly that reason), and before the work the cost is
unknown — so what is held is an estimate from estimateTokens. settleAuto
replaces it with the measured total afterwards, including when the run failed
or was aborted: a generation that died in its third slide still spent those
three slides' tokens, and charging it a full deck is a refund request. A
generation carries its OWN meter rather than the request's, because it outlives
the request — /generate returns once the run is registered, and the client may
drop and reattach.
Tiers. users.plan, and PLANS in src/limits.js. The admin settings
define the free tier, which is what every account was already given, so an
operator who has tuned them keeps the budget they tuned; a paid tier is a
multiple of it, so there is one place to adjust "how much is a little" and the
paid tiers cannot drift out of proportion. Budgets scale; windowHours and
maxSlidesPerDeck do not, because those describe the shape of one piece of
work rather than an allowance. unlimited is uncapped rather than very large,
and exists so the operator's own account can be exempted without raising the
caps for everyone. BYOK is deliberately not a tier: a user on their own key
costs the operator nothing and isAutoRoute excuses them before any of this is
consulted.
Wiring the meter surfaced that four routes spent on the gateway with no quota
check whatever — report/generate, script, sweep, and
generate/resume/finalize. Any new route that reaches a model must reserve
and settle; there is no ambient enforcement that would catch one that forgets.
BYOK bypasses the operator's Auto quota but not safety enforcement.
src/byok-budget.js owns a per-user rolling 24-hour budget stored through
user_prefs.byok_daily_tokens and byok_events; the shipped default is 180,000
tokens. The enforcement point is cloudChat, after routing has identified a
non-Auto provider and before each fetch. This covers both local and hosted
deployment and every caller above the transport, including retries and repair
passes. Reservations are atomic SQLite transactions, then settle to reported
usage or a prompt/response estimate. Failed attempts remain reserved because a
provider may complete after the client times out.
The transport independently limits a BYOK response to 12,000 tokens, disables
automatic length-cap doubling and permits at most one transport retry. The key
save endpoint requires a cost-responsibility acknowledgement and records its
timestamp in user_prefs.byok_terms_accepted_at. Profile shows the rolling
usage, remaining budget and a typical-deck estimate, and lets the key owner set
the ceiling. This is not monetary accounting: provider price, cache discount,
tax, currency and invoice remain external, so the UI and Terms direct the user
to set a hard billing cap with the provider.
Accounts are deliberately NOT a multi-user system: they exist so the Cloud-key section can be gated (the key is the one sensitive thing on the box) and to have a place for future hosting. The deck/report/preset workspaces require a session. Everything else — themes, models, identity GET — is open.
Per-user deck access is one policy. canAccessDeck(user, owner) in
src/auth.js is the single decision used by the deck list, the per-slug
detail/render/report gate, the content search and the sweep: owned decks belong
to their owner; ownerless decks (legacy folders, headless CLI runs) are the
operator's — visible and editable only by an admin account, so a fresh account
does not see a stranger's CLI decks as its own. The admin is the seed account
(created by seedAdmin with a role: "admin"), with FORGE_ADMIN_EMAIL
matching as a fallback for accounts minted before roles existed. The CLI is
unaffected — it runs headless and never goes through the server's auth.
src/auth.js owns the logic, app/server transports it. Passwords are hashed
with crypto.scrypt and a per-user random salt; the users file holds only
salt+hash, and the password is never logged, stored, or returned. Sessions are
opaque random bearer tokens held server-side in a gitignored sessions file —
the browser only ever sees the token, and tokens idle-expire after
FORGE_SESSION_TTL_DAYS (default 30), refreshed on use and pruned when dead.
POST /api/auth/login mints a session. Hosted registration returns the user
without a token and the visitor signs in explicitly, so ordinary registration
never silently logs anyone in. Private local-owner setup is intentionally
different: registerLocalOwner() atomically creates the first verified admin
under BEGIN IMMEDIATE, starts its session, and refuses every later setup
attempt. POST /api/auth/logout
and GET /api/auth/me back the header's account entry. PUT/DELETE /api/cloud/key return 401 without a session, and the Settings modal's Cloud
section shows a login prompt until one exists. The AuthModal — reachable from
the landing's Log in / Sign up actions and from the shell — is the register/
login surface (the full-screen LoginScreen was retired when the landing became
the front door); both config/users.json and config/sessions.json are
gitignored.
Registration has two explicit postures. The normal root Compose bundle is a
personal install: GET /api/auth/registration reports localOwner and whether
an owner exists, so the browser shows one setup action on a fresh volume and a
login action afterwards. FORGE_LOCAL_MULTI_USER=1 restores ordinary trusted
local registration for legacy shared-machine use. Hosted mode follows
FORGE_OPEN_REGISTRATION; when closed, the owner's account is seeded at boot
from FORGE_ADMIN_EMAIL + FORGE_ADMIN_PASSWORD (idempotent — a restart never
errors). The browser does not infer any of these states from SMTP availability.
Destructive and config-writing endpoints are gated. POST /api/sweep
(deletes decks, and therefore operator-only), PUT /api/identity, brand
POST/DELETE and PUT /api/cloud/routing all require a session — an
unauthenticated visitor can no longer wipe decks or deface the identity/brand.
Heavy specimen renders are gated too, and the render endpoints run behind a
one-at-a-time mutex so a home box with a single CPU never stacks concurrent
LibreOffice/Chrome jobs. Slugs are format-validated (^[a-z0-9][a-z0-9_-]{0,99}$)
before the
img-tag exemptions, closing the traversal class in one place.
Uploads are raster-only and verified. SVG was dropped from the deck and
brand upload allowlists (an SVG can carry a script into the token-in-localStorage
origin), and every upload is checked against its magic bytes, so a smuggled
HTML file named .png is refused. Served assets carry a sandbox CSP.
Headers. The served UI gets a strict CSP (default-src 'self' + inline
styles); API JSON does not. With no FORGE_UI_ORIGIN configured, no CORS
headers are emitted at all. FORGE_TRUST_PROXY=1 opts a reverse proxy into
X-Forwarded-For handling for the rate limiter.
Two gaps that a stranger hits on day one and a demo never shows: a forgotten password with no way back, and an address nobody can reach. Both are answered by one token store, because they are the same job — prove you can read this inbox — differing only in lifetime and in what spending a token does.
One table, auth_tokens, holding hashes. token_hash is the SHA-256 of the
token; the plaintext exists only in the email. A reset token is a bearer
credential for an account, so a database that leaks must not be a list of live
password resets. Two near-identical token implementations is how one of them
ends up missing the single-use check, so issueAuthToken / consumeAuthToken
take a purpose (reset | verify) and everything else is shared. Issuing
drops any outstanding token of the same purpose — two live reset links doubles
the window an intercepted message is useful in. Consuming is a conditional
UPDATE … WHERE used_at IS NULL and checks the row count, not a read followed
by a write: two requests arriving together on one token would both pass a read
check, and a single use that two callers can satisfy is not a single use.
A completed reset kills every session for the account (resetPassword in
src/auth.js). A reset is what someone does when they believe they have lost
control of the account; leaving alive the sessions that prompted it answers the
wrong half of the problem. It also proves the address, so it confirms an
unconfirmed account.
POST /api/auth/forgot answers identically whatever the truth is. Whether
the address has an account, whether that account signs in with Google and has no
password to reset, and whether the message was accepted are all invisible —
otherwise the endpoint is an account-enumeration oracle for a box that never
listed its users. The send is dispatched rather than awaited for the same
reason: an SMTP dialogue takes seconds, and awaiting it makes a real address
measurably slower than an unknown one. A per-address throttle sits beside the
per-IP limiter: the IP limiter stops a dictionary attack and does nothing about
one inbox being mailbombed from a botnet.
POST /api/auth/verify does not start a session. A link sitting in an inbox
should confirm an address, not be a way into the account.
Confirmation is enforced only where a message can be delivered.
verificationRequired() is mailConfigured(). An install with no SMTP marks
accounts verified at creation, because a gate whose only key is an email nobody
can send is a locked door with no handle. Google accounts arrive confirmed —
verifyGoogleIdToken already refuses a token whose email_verified is false,
so reaching findOrCreateGoogleUser is the proof the email flow exists to
obtain. Rows that predate the verified_at column are backfilled as confirmed:
those accounts registered when nothing was asked of them, and a migration that
locks all of them out of their own decks is a worse failure than the one
confirmation prevents.
The gate is default-deny by method, at the workspace entrance.
verifiedRequestOnly({ method, path }) lives beside canAccessDeck in
src/auth.js — reading is free, changing anything is not, DELETE stays open
so someone who cannot generate can still clear up, and POST /search is a local
filter that spends nothing. Stated that way, a route added later is covered
without anybody remembering to cover it; a list of the routes that currently
spend something goes stale on the sixteenth.
It sits at the workspace rather than on the Auto tier even though Auto is what
costs the operator money, for two reasons. Routing resolves per request in
src/cloud.js and falls back, so "your own key may generate, the shared one may
not" would leak the moment a preference resolved to Auto. And the key is not the
only cost: a deck is 5–15 MB of rasters and two LibreOffice passes on the
operator's machine whoever's key paid for the words. What an unconfirmed account
keeps is everything that spends nothing — signing in, browsing, Settings, its
own BYOK key, identity and brand.
The refusal carries code: "email_unverified" so the UI can turn it into a
resend prompt rather than the generic message it shows for everything else. The
composer is disabled ahead of it: the briefing runs entirely in the browser, so
without that the person answers five questions about a deck the server will
refuse to create.
src/mail.js takes a recipient per message. It began as "mail the operator
on sweep day", so FORGE_SMTP_TO was the only address it could reach; that is
now the sweep's destination alone. mailConfigured() asks whether outbound mail
works at all, sweepMailConfigured() whether the sweep has somewhere to report.
Links are built from FORGE_PUBLIC_URL and carry the token in the URL fragment,
which is never sent to the server, never lands in an access log, and is not
forwarded in a Referer when the page loads its own assets. #/reset/<token>
and #/verify/<token> render above the auth gate in App.jsx, because someone
resetting a password cannot log in by definition.
#/verify spends its token on mount, which makes it the one screen where React
running an effect twice is a correctness bug rather than a wasted request: the
first call spent the token, the second was told it was already used, and the
panel reported a dead link for an address it had just confirmed. It now goes
through singleFlight (app/web/src/lib/singleflight.js) — one call per
token, the promise shared so every run reads the same outcome. #/reset is
submit-driven and never had the problem.
mailConfigured() is load-bearing beyond sending in normal/hosted auth. It also decides whether
address confirmation is enforced: with no SMTP, accounts are verified on
creation, since a gate whose only key is an email nobody can send is a locked
door with no handle. The consequence for development is that these flows do not
run at all on a box without SMTP — not a weaker version of them, none of them.
npm run dev:mail starts tools/mailsink.mjs beside the app so they can be
exercised; it speaks the dialogue src/mail.js sends and prints each message
with its link. Personal local-owner mode bypasses that ambiguity entirely:
registration state explicitly reports no mail or verification, and the Google,
forgot, reset, verify and resend routes are unavailable.
The institutional .docx is gitignored and excluded from the build context, so
a fresh hosted box has none and every report route fails while the rest of the
product looks healthy. donorStatus(refDir) in src/report.js names the three
states — missing, ambiguous (several present, and resolveDonor refuses to
guess), or serving one — and is read in three places: the boot log, the admin
System tab, and createReport.
createReport checks it before the research pass. That run is a web
research pass, a full model write and then a render, so reaching the donor only
at the render meant the failure landed after every minute of that and after the
account's Auto budget was gone — to report something knowable before the first
request went out. The check is in the pipeline rather than the route, so the CLI
is covered too. POST /api/decks/:slug/report/render answers 503 with
code: "no_donor" instead of a 500 from the renderer: a server that is not
fully installed is not a broken request.
donorStatus takes its directory as a parameter rather than reading the
constant, which is what let the donor become per account without changing
shape. donorDirFor(owner) picks an account's own directory when it holds a
template and falls through to the install-wide one otherwise;
donorDirForDeck(deckDir) reads meta.owner and is what every render passes
through, so renderReport(reportFile) resolves the right template with no
caller threading a user through — the same trick as loadIdentity(deckDir).
The bare donorStatus() still answers for the operator's default, which is
what the boot log and the admin panel want.
The app is local-first by default and stays that way: nothing calls a hosted
provider unless the user attaches one. config/models.yaml already allowed a
role to opt into an openai-compatible provider; the shell now makes that
usable end-to-end.
src/cloud.jsowns provider resolution and the credential surface. Shared operator keys resolve from the environment before the encrypted global vault; personal keys live encrypted per account in SQLite and override the shared key for that account. No code path returns a key value: status is booleans and labels, and the connection test reports success/failure text only. A provider's model list is the staticmodels:array when declared, otherwise it is fetched live fromGET {baseURL}/models(key-gated, best-effort, both the{data:[{id}]}and string-array shapes) — so a hosted box whose provider does not curate a list still gets a working picker.- The Profile/Cloud panel explains what the key is for and offers save,
remove and test actions wired to
/api/keysand/api/cloud/test. An unsaved draft can be authenticated in one request without being persisted. Hosted storage is disabled with an actionable message untilFORGE_KEY_PEPPERis configured; local installs retain the development-only vault default. - Routing. A
modeloverride in a request that names one of a cloud provider'smodels:list routes that request to the provider instead of a (likely absent) local pull — that is how pickingdeepseek-v4-flashin the prompt, chat or report picker sends the work to OpenCode Go. The model picker lists the cloud models as a labelled group only when a key is present. - Routing preference.
config/local.yamlalso holdsrouting.default(localorcloud), set from the header's LOCAL/CLOUD toggle or the Cloud panel. It decides what "auto" means in every picker: the default label reflects it, and an unpicked model routes the author role to the attached provider's first model. Research, utility and critic stay on their configured backends — the toggle moves the user's work, not the plumbing. Cloud research is therefore a role-level choice: giving the research role aprovider:switches its depth profile to the deeper cloud budgets (see the per-transport split above). - The connection test is an authenticated probe.
/modelslists are public on some providers and prove nothing about a key, so the test issues a one-token completion against the first configured model, choosing/responseswhen the provider declares that transport and/chat/completionsotherwise. A dummy key fails with the provider's status; a real key reports the authenticated model. - Cloud structured output. OpenAI-compatible providers reject
response_format: json_objectunless the prompt contains the word "json", and guarantee only that output parses — never that it matches the schema (unlike Ollama's grammar). The cloud transport prepends a system message stating the schema contract verbatim, so every constrained call spells out its keys and a strong model can hold the small per-call schemas in context.
brand/logos/ and brand/generated/ are gitignored because institutional
marks are trademarks. The Brand marks section of the Settings modal's Identity
panel uploads crest, banner and watermark directly: the server writes the file
into brand/logos/
(replacing any earlier extension of that asset), then calls the same
normalizeBrand() the npm run brand CLI uses — one implementation, two
callers. Remove falls back to placeholders. The report renderer needs nothing
extra: it preserves the donor .docx's own VML watermark byte-for-byte, so
reports already carry the institution's mark.
The three-layer rule is unaffected: the shell is human-owned chrome, and
decks/<slug>/report.yaml is written by the report generator exactly as
deck.yaml is — nothing in the UI ever specifies geometry.
src/presets.js stores the reusable half of a briefing per user
(config/presets/<user>.json, gitignored): team, guide, academic context,
theme, density, branding and slides-per-member. The briefing's first question
offers them, and picking one pre-fills those fields and skips their questions
while the changing bits (title, subject, teacher) stay answerable. The server
CRUDs them behind the same session gate as the deck workspace; "Save as
preset" lives on the briefing's summary card. The skip logic is a client-side
walk over the per-kind question list (effectiveBriefStep in
lib/briefing.js), and a "change" click on a preset-fixed question un-skips
it for that deck.
The app is built to run on a friend's old Linux box. A Dockerfile (Node 24 +
LibreOffice + Chrome) and docker/docker-compose.app.yml pair the app
container with the bundled SearXNG; the API serves the built UI and SPA
fallback from one port; the stateful directories are env-pointed at a named
volume. The monthly sweep (src/sweep.js) deletes decks older than
FORGE_SWEEP_DAYS unless meta.keep: true, runs daily at FORGE_SWEEP_HOUR
from the server's own scheduler (env-gated — no env, no sweep), and emails the
owner via src/mail.js, a dependency-free SMTP client. Light hardening for a
public box: secure headers, a CORS allow-list (FORGE_UI_ORIGIN), rate-limited
auth endpoints, and validated uploads (extension + size) for brand marks and
deck images. POST /api/sweep and npm run sweep run the sweep manually.
The web UI paints from one token set with a single thesis: the interface is achromatic; the decks are the colour. Thirty-four themes carry every hue this product owns, so the shell stays ink-on-paper and never competes with the thing it is rendering.
app/web/src/styles.css defines light on :root and redefines the same names
under [data-theme="dark"]. The accent is near-black in light and near-white
in dark — an inversion, not a hue change — which is why every surface that
paints ON a filled ground reads --color-on-accent or --color-on-danger
rather than assuming white. A white foreground on an inverted accent is
invisible, not merely wrong, and that is how the whole app looked the first
time a dark mode existed.
Three text levels and the three status colours are chosen against the WORST
ground they appear on — the sunken and hover surfaces, and their own 10%
washes — not against white. A ramp picked against white alone puts tertiary
text at 2.56:1 and amber at 1.94:1, which is not a colour, it is a
suggestion.
lib/appearance.js is the only thing that decides which theme is in force.
Three modes, not two: "system" has to be reachable, or the preference stops
tracking a machine that changes at sunset. The resolved theme lands on
data-theme and the mode on data-appearance, because "light" the preference
and "light" the outcome are different facts. The pre-paint script in
index.html duplicates that read deliberately — a module that resolves after
first paint flashes the wrong ground on every load.
The landing is a run of pinned SCENES rather than a stack of blocks. A scene is
a tall wrapper containing one position: sticky frame; the wrapper's height is
a scroll budget, and lib/scene.js turns how much of it has been spent into a
number every scene animates off. Vertical scroll is the only input, so a reader
who does nothing but scroll still sees the whole product.
position: sticky, not a pinned GSAP timeline. A pin rewrites the document
with spacer elements and takes ownership of the scroll, which breaks on resize,
on zoom and under a touch fling; sticky is the browser doing the same job
natively, and it needs no second code path for reduced motion because the
position IS the scroll — there is no animation to disable.
Two constraints govern anything added here.
Nothing up a scene's ancestor chain may set an overflow property. overflow-x: hidden with overflow-y: visible is invalid CSS, so the browser promotes the
visible axis to auto, and an element with overflow-y: auto is a scroll
container — which is what sticky sticks to. The scenes then hold still relative
to a box that is not the viewport, and the reader scrolls through blank page.
Clipping the sticky FRAME itself is fine and is how the entry transforms are
kept from widening the document.
A pinned frame is exactly one viewport tall, so content taller than that is
cut off. Where a scene's pinned and flowing layouts render the same DOM, the
content's own height decides whether to pin (useTooTall) — which also catches
a short desktop window that no breakpoint would. Where they are genuinely
different content, as with the pipeline stacking into seven steps, measuring
would oscillate and a media query decides instead (useNarrow).
tools/landing.mjs renders decks/_public/landing/deck.yaml across a chosen
cast and commits the frames to app/gallery/landing, with a manifest carrying
each frame's dimensions, its measured darkness and the slide-type count. The
same reasoning as app/gallery: regenerating needs LibreOffice, Poppler and
the theme fonts, which a container's first boot should not be asked for.
They are real renders because the page is a claim about what the renderer
produces — a hand-drawn mockup of a slide is a claim about what someone hoped
it produces, and the two drift the moment a layout changes. Darkness is
measured off the painted pixels, never a palette token: swiss-international
declares a white page and renders a near-black cover.
The pipeline scene is the exception and goes the other way: its seven panels are hand-written markup of the actual interface, so they use the app's own tokens, stay selectable and sharp at any zoom, and cost kilobytes.
The account system began as a gate on the operator's cloud key, and for a while several surfaces stayed install-wide while looking per-user. On a hosted box that is not a hardening detail, it is a correctness bug: two students from different colleges get each other's institution on their slides.
Five things are now per account, and one deliberately is not.
Identity (src/ai/identity.js) resolves in four layers: the committed
template, the operator's config/identity.yaml, the account's own overrides,
then the deck's meta.yaml. The account layer needs no plumbing — the deck
folder already records meta.owner, so the same read that supplies the last
layer identifies the third. A deck therefore renders with the identity of
whoever owns it, whatever anyone else has since changed in Settings.
Brand marks follow identity. Each account normalises into
brand/users/<hash>/ and its identity points at those paths; an account that
has uploaded nothing falls through to the operator's marks rather than getting
placeholders. normalizeBrand({ srcDir, outDir, placeholders }) is the one
implementation, called with different directories.
The report donor follows the same rule and was the last to get there. It
supplies the headers, margins, footer and watermark a report is graded on, so
an install-wide one puts one college's letterhead on another's submission —
and nothing fails while it does: the .docx renders, it is simply the wrong
document. An account uploads its own through POST /api/donor (Settings →
Identity); GET /api/donor reports own and effective separately because
they differ in the ordinary case, not a broken one. The admin route stays for
the install-wide default.
BYOK keys were the sharpest edge: they were stored encrypted per user and
then never read, because the model client resolved env:NAME against the
install-wide config. Every "bring your own key" generation actually billed the
operator. resolveProviderKey() now prefers the calling account's vault entry
for every provider except the shared gateway.
Routing (Auto vs Cloud) is per account in user_prefs, with the
config/local.yaml value as the default for accounts that have never chosen —
and as the whole answer for the CLI, which has no account.
The shared gateway key is deliberately install-wide. It is the operator's
key, billed to the operator and rate-limited per user by src/limits.js. Only
an admin may set it.
chat() sits five or six calls below the HTTP layer, and the CLI must keep
working with no account at all. Threading a user id through pipeline →
generate → turn → chat → resolveRole would put a parameter nobody else
needs into every signature.
src/account.js uses AsyncLocalStorage instead: one middleware makes the
caller ambient for the request, and anything underneath asks
currentUserId(). A detached generation task started inside a handler inherits
the context, which is what makes a multi-minute SSE run resolve the right key.
Outside a request there is no account and every lookup falls back to the
install-wide configuration — exactly the old behaviour.
isAdmin() reads user.role and nothing else. It used to also return true for
a hardcoded email and for FORGE_ADMIN_EMAIL, but registration does not verify
email ownership, so on a public box whoever signed up with that address first
owned the instance. The env var still designates the operator — at boot, where
seedAdmin() creates the account or promoteToAdmin() promotes an existing
one, and where nothing reachable over HTTP can interfere.
Rasters, downloads and uploaded assets used to skip authentication entirely,
because an <img> tag cannot send an Authorization header and the slug was
assumed undiscoverable. A slug is slugify(title), so it was guessable, and
the whole deck store was readable by anyone who could guess a title.
They now authenticate with an httpOnly cookie carrying the same session token,
scoped to Path=/api/decks and accepted only on those media paths. Every
state-changing route still reads the bearer header and ignores the cookie, so
broadening the credential cannot broaden CSRF exposure. Ownership is then
checked exactly as it is for the deck itself. The cookie is marked Secure on
an HTTPS request, which is why the deployment terminates TLS.
An owned deck's slug now carries a random four-character token, which makes it
harder to guess — and that is a side effect, not the reason. uniqueSlug mints
it because the collision counter was the leak: appending -2, -4 against
the one flat decks/ namespace reported how many other accounts held the same
title. The token is unconditional so that its presence says nothing either, and
it is still not a credential: assertDeckAccess is. Ownerless decks — the CLI,
the tests, a single-operator install — keep the readable counter, because there
is no second account to leak to and decks/<topic> is the name every sweep and
--deck flag refers to.
| Path | Committed | Notes |
|---|---|---|
themes/, schema/, src/, app/ |
yes | source |
config/identity.yaml |
no | the OPERATOR's install-wide identity default; per-account overrides sit above it. identityStatus() reports it as missing / template / incomplete / ok at boot and in Admin, because an unset default is wrong silently — the deck renders, under the wrong institution |
config/identities/<hash>.yaml |
no | one account's identity overrides — institution, guide, brand paths |
config/local.yaml |
no | the install-wide provider key + the fallback routing.default; per-account keys live encrypted in the DB |
config/forge.db |
no | accounts, sessions, per-user BYOK keys (AES-GCM), routing, Auto usage, BYOK budgets/usage and cost-acceptance timestamps |
config/users.json, config/sessions.json |
no | local accounts (scrypt hashes) and their bearer-token sessions — the auth gate |
config/presets/ |
no | saved briefing formats, one file per user |
config/uploads/ |
no | staged briefing documents — upload-only research mode; short-lived (swept after the TTL), resolved by token at planning |
brand/logos/ |
no | the operator's raw marks — trademarks stay local |
brand/users/<hash>/logos, .../generated |
no | one account's own marks, uploaded via the Brand panel |
brand/generated/, brand/fonts/ |
no | reproducible via tools |
decks/<slug>/deck.yaml, meta.yaml |
yes | the deck |
decks/<slug>/plan.yaml |
yes | the approved outline |
decks/<slug>/research/ |
yes | the shared research pass (notes.md + sources.json) both artefacts draw from |
decks/<slug>/out/ |
no | rendered artefacts |
decks/<slug>/assets/ |
no | uploaded slide images (gitignored, referenced as assets/<file>) |
decks/.specimen-cache/ |
no | type-swap gallery renders, cached per theme |
.plate-cache/ |
no | headless-Chrome plate PNGs, keyed by content hash |
decks/<slug>/chat.jsonl, decisions.md |
no | per-deck thread state |
decks/<slug>/backups/ |
no | timestamped deck.yaml snapshots — the version history (F14) |
decks/<slug>/report.yaml |
yes | the report content (sibling of deck.yaml) |
templates/ |
yes | reusable partial slides the "+ Add slide" menu inserts |
reference/ |
no | the report donor .docx; gitignored because it carries third-party names, and FORGE_REFERENCE_DIR points it at the volume on a hosted box where an admin uploads it. reference/users/<hash>/ holds an account's own template, which overrides it |