// --------------------------------------------------------------------------- // home/HomePage.tsx — WAVE 23 item 9 (ruling R7, contract C10): the landing. // // The layout is `reference/Airtable Home.png`, in OUR tokens: an `h1`, a row of // quick-start cards, then the recently-opened databases under Today / Past 7 // days / Older, with a list/grid toggle. Nothing is copied but the ANATOMY — // every colour, size and weight is the Loopable palette (R1's rule for the // builder, applied here for the same reason). // // ⛔ THIS PAGE IS CHROME, NOT A GRANTED SURFACE, and the distinction is the one // the shell's oldest law turns on (`Shell.tsx` :1039 — "the nav is // server-filtered and an undeclared surface is denied"). Home does not violate // that law because it cannot: every database it names is resolved from the // `entries` the server returned (`homeModel.groupRecents` drops a recent whose // key is not in them), and the four cards open doors the rail already offered. // There is no payload here that `GET /nav` did not send. // // It holds NO state of its own beyond the layout toggle. The recents come down // with the nav; the four actions are the frame's, because the frame owns the // dialogs and the hash. // --------------------------------------------------------------------------- import { useEffect, useState } from "react"; import { FolderMark } from "../customer-grid/icons"; // ⭐ WAVE 26 · ITEM 18 / R14 + C7 — THE LOOP MARK ON HOME'S FOURTH `.auto-dot` SITE. // The scout table named three call sites; this is the fourth, found by SESSION B and handed over // rather than reached across the fence. Same rule as the rail: consume the component that points // at the generated artifact, never inline the path ([[loopable-nav-logo-toggle]] — one element // paints the mark). import { AGENTS_MODULE_LABEL } from "../inbox/inboxModel"; import { Mark } from "../shell/Brand"; import { dbChipClass } from "../shell/nav"; import type { DatabaseEntry, Recent } from "../shell/nav"; // ⭐ WAVE 35 · W35-T15 (R4, C2) — ONE STAR CONTROL, drawn in the module that owns the concept and // worn by both surfaces. R4 makes star and "mark important" one idea and one flag; a second star // component here would be the "one mark, one meaning" failure DESIGN.md 4 names, and `useStarred` // is what keeps Home and Starred from ending up with different ideas of what is starred. import { StarButton, Tile } from "../starred/StarredPage"; import { shapeStarred, useStarred, viewCount } from "../starred/starredApi"; import type { StarKind, Starred, StarredCounts, StarredLoad } from "../starred/starredApi"; import { HOME_LAYOUT_KEY, allDatabases, parseLayout, } from "./homeModel"; import type { AutomationTile, HomeLayout } from "./homeModel"; // ── the four quick-start marks ─────────────────────────────────────────────────────────────── // // Drawn here, in the same 16x16 stroke vocabulary as the rail's `DbIcon`/`AutoIcon`/`BellIcon` // — the established pattern in this tree (the shell and the alerts pane each draw their own // bell rather than sharing an icon module). DESIGN.md's rule is "extend the set, don't import a // new icon language", and these are the same language: 16x16, `currentColor` strokes, no fills, // no emoji. /** Templates — stacked sheets, the thing a template gives you. */ function TemplateIcon() { return ( ); } /** A blank database — the rail's cylinder with the plus that makes one. */ function NewDbIcon() { return ( ); } /* * ⭐ WAVE 26 · ITEM 18 / R14 — `AutomationIcon` STOOD HERE AND IS DELETED WITH THE DOT. * * It drew two nodes and the edge between them. R14 replaces it with the Loopable loop mark on * the grounds that *"these automations are basically loops"* — so the tile's chip now carries the * same mark the rail does, and this hand-inlined path had no second caller left. (It had already * survived one deletion: wave 25 R8 removed the "Automated database" card and KEPT this because * the tiles still used it. The tiles no longer do.) * * ⛔ NOT REPLACED BY A NEW SVG. C7 is explicit — consume the existing component. The mark's one * source of truth is generated, and `shell/Brand.tsx`'s header records that a hand-redrawn copy * silently painted LAST WAVE'S BRAND while a comment claimed parity. */ /** * Connect a source — a two-prong plug with a cord, which is the thing you put in an outlet. * * ⭐ WAVE 35 · W35-T15 (owner item 5): *"change the Icon for the connector to look like the cable * head that you would put in an electrical outlet."* It drew two links of a CHAIN until now, and * so does its twin on the rail (`shell/Shell.tsx::PlugIcon`, whose NAME has been wrong since wave * 23). The prongs are the load-bearing part of the drawing; the cord is what stops it reading as * a padlock at 16px. * * ⛔ THE TWO COPIES MUST NOT DIVERGE and they are in two different fences, so the geometry below * was published in mailbox B-5 as an ASK to the session that owns the rail (W35-T04) rather than * changed here and hoped about. One glyph, one meaning; two plugs that disagree is worse than the * chain, because at least the chain was wrong in both places identically. */ function ConnectIcon() { return ( ); } /** The two layout marks: rows, and a 2x2 of tiles. The reference pair, in our stroke weight. */ function ListIcon() { return ( ); } function GridIcon() { return ( ); } /** * One quick-start card. A ` ); } /** What the FRAME hands Home. Unchanged by the wave-35 split, and named so both halves take * exactly the same shape rather than drifting. */ export interface HomeProps { /** * The SHAPED, server-filtered nav. The only thing a tile may be drawn from. * * ⭐ WAVE 25 (D-54) — `DatabaseEntry[]`, NOT `NavEntry[]`, and the narrower type IS the fix. * This page draws a "Databases" section, so it is entitled to the DATABASE list; the raw nav * also carries the Automation SURFACE, which this page drew a tile for until the wave-24 visual * pass caught it by eye. A brand only `databaseEntries()` can produce turns "somebody passed * the wrong list" from a defect a human has to spot into one `npx tsc -b` refuses to build. */ entries: DatabaseEntry[]; recents: Recent[]; /** * ⭐ WAVE 24 item 13 (R10, wiring W24-W3) — every automation this account may open. * * A REQUIRED prop, never optional: an optional one degrades to "the section does not exist", * which is indistinguishable from "it was never built" and is precisely the wave-20 failure * this repo shipped four times. `[]` is a legitimate value and renders no section; ABSENT is * not a state the type allows. * * ⚠ An account without the automation grant gets a 403 from `/automations`, and the FRAME * turns that into `[]` — the section then simply does not render, which is the correct answer * rather than an error card about a surface they may not have. */ automations: AutomationTile[]; onTemplates: () => void; onNewDatabase: () => void; /* ⛔ `onAutomated` LEFT WITH THE CARD IT OPENED (wave 25 item 5a, R8). */ onConnectors: () => void; /** Open one automation: the frame owns the hash, the surface owns which one is selected. */ onOpenAutomation: (id: string) => void; /** * ⭐ WAVE 35 · W35-T16 (owner item 9) — open a starred VIEW inside its database. * * ⛔ REQUIRED, and the frame's, for the same two reasons `onOpenAutomation` is. Optional would * degrade to "clicking a starred view does nothing", indistinguishable from the section never * having been built and red in no gate. And the act is a hash change PLUS a request to the grid * for which view is selected: the grid's listener DROPS a viewId it has not loaded yet with no * acknowledgement, so the emit needs the frame's bounded retry ladder against a MEASURED worst * case, not a single hopeful dispatch. * ⚠ `Shell.tsx` passes the IDENTICAL handler to Home and to Starred (mailbox A, `DONE B-8`), so * a view opened from one cannot behave differently from the same view opened from the other. */ onOpenView: (database: string, viewId: string) => void; } /** * ⭐ WAVE 35 · W35-T15 — THE PURE HALF, AND IT EXISTS SO HOME CAN BE LOOKED AT. * * Home held no state that could not be rendered until the star list arrived. `renderToStaticMarkup` * never runs an effect, so a static render of the fetching component can only ever photograph the * state where the stars are still unknown, i.e. the one state with no star in it. Splitting the * read off is what makes "hover reveals an outline star, a starred tile shows a filled one" a thing * a person can SEE without a server. `inbox/_inbox_shot.tsx` paid for this lesson first; the same * split is now on `StarredSurface`. * * ⚠ THE DEFAULT EXPORT'S PROPS ARE UNCHANGED, so the frame's mount is untouched by this. */ export function HomeSurface({ entries, recents, automations, onTemplates, onNewDatabase, onConnectors, onOpenAutomation, onOpenView, starLoad, starred, busy, counts, toggle, }: HomeProps & { starLoad: StarredLoad; starred: Starred; busy: boolean; /** C3's row counts, or `null` while the second read is in flight (R7: after paint, never on nav). */ counts: StarredCounts | null; toggle: (kind: StarKind, id: string, on: boolean, database?: string) => void; }) { const [layout, setLayout] = useState(() => { try { return parseLayout(localStorage.getItem(HOME_LAYOUT_KEY)); } catch { return "grid"; } }); useEffect(() => { try { localStorage.setItem(HOME_LAYOUT_KEY, layout); } catch { // storage can be blocked; the toggle still works for this session } }, [layout]); // ⚠ THE CLOCK IS READ ONCE, HERE, AND HANDED IN AS A NUMBER. `homeModel` stays pure so a gate // can assert the ordering rather than whatever today happens to be. // ⭐ WAVE 24 item 13 (R10) — `groupRecents`' Today / Past 7 days / Older buckets are GONE, and // with them the reason a database you had never opened was invisible here. `allDatabases` draws // every entry the server granted and uses the recents stamps only to ORDER them. The local // midnight this used to compute has no remaining reader, so it is not computed. const now = Math.floor(Date.now() / 1000); const databases = allDatabases(entries, recents, now); // ⛔ THE STAR CONTROL IS ABSENT UNTIL THE LIST ARRIVES, deliberately. Drawing an empty star // before the list is known would STATE that nothing is starred and then flip under the reader // once it lands. An affordance that appears a moment later is a smaller cost than a control // that was wrong for a moment, and this one is revealed on hover anyway. const known = starLoad.phase === "ready"; const dbStarred = new Set(starred.databases); const agentStarred = new Set(starred.agents); // ⭐ WAVE 35 · W35-T16 (owner item 9) — the starred VIEWS, resolved by the SAME function // Starred uses. Item 9 asks for these views on Home and under Starred; running them through one // resolver is what stops the two lists disagreeing about which views exist or what they are // called, which is item 11's complaint applied before it can happen. // ⛔ AND THE DROP RULE COMES WITH IT: `shapeStarred` refuses a view whose database is not in // `entries`, so a star that outlived its database (deleted, or a grant revoked) draws NOTHING // rather than a tile that lands the reader in a 403. const starredViews = shapeStarred(starred, entries, automations).views; return (

Home

{/* ⚠ THE DETAIL LINES ARE BYTE-IDENTICAL TO THE DIALOG'S ROWS, and that is deliberate rather than lazy. Each of these cards opens the same door as a row in the New-database dialog, and the first draft described the same two things in four slightly different sentences ("fills and keeps up to date" vs "creates and keeps up to date"; "ready-made views" vs "a curated set of views"). Two words for one thing is how a reader concludes they are two things — DESIGN.md 1's "variety is a defect". The TITLES do differ, and that is a register difference the reference makes too: a card is an invitation ("Start with templates"), a menu row is a choice ("From a template"). */} {/* ⭐ WAVE 24 item 14 — this card now opens an UNDER-CONSTRUCTION note, not the picker. ⚠ THE CARD ONLY, AND THE ASYMMETRY IS DELIBERATE (see the wave doc's C-HOME note): ruling R9 KEEPS "From a template" working inside the Database flyout's create menu, and item 14 names this card by its exact title. Both instructions are followed literally, which does mean one template door is boarded while another works. Written down so the next session does not "fix" it in either direction — if it reads wrong live, it goes back to the owner, not to a session's judgement. The detail line is unchanged: it still describes what the feature WILL do, and the note behind the click is what says it is not ready. A card that described its own unavailability would be the tour DESIGN.md 4 forbids. */} } title="Start with templates" detail="Add a curated set of views to a database you already have." onClick={onTemplates} /> } title="New database" detail="A blank database in this workspace." onClick={onNewDatabase} /> {/* ⛔ THE "AUTOMATED DATABASE" CARD STOOD HERE AND IS DELETED (wave 25 item 5a, R8). It promised one act that was really two — make a database, and point an automation at it — and the card could only ever do the second, because it handed off to the automation surface and left the user to name a database there. R8: "creating a database is one act; pointing an automation at it is another." `New database` above is the first; the automation surface's own `New automation` is the second. ⚠ THREE CARDS NOW, and that is the whole visual delta on this row. The grid is `auto-fill`/`minmax` so it re-flows rather than leaving a hole — verified in the static render, not assumed. */} } title="Connect a source" detail="Browse the connectors this workspace can use." onClick={onConnectors} />
{/* The reference puts an "Opened anytime" filter here. We do not have one and do not pretend to: a control that filters nothing is worse than no control. The toggle is the real affordance, so it sits alone. */}
{databases.length === 0 ? ( // ⛔ ONE LINE, AND IT NAMES THE DOOR THAT FILLS IT (DESIGN.md 4 / R13). This is also // where wave 18's tenant hero went: a workspace with no databases lands here, and the // cards above ARE the "create your first database" affordance the hero carried. Two // welcome messages on one screen is the bug Shell.tsx's empty-nav note already documents. // ⭐ WAVE 24: the second sentence is GONE with the recency buckets. "Nothing opened yet" // described a state that can no longer exist — every granted database is drawn whether it // has been opened or not, so an empty list now means exactly one thing.

This workspace has no databases yet — create one above, or connect a source.

) : ( [{ id: "databases", title: "Databases", tiles: databases }].map((section) => (

{section.title}

{section.tiles.map((tile) => ( // ⚠ THE WRAPPER IS ALWAYS PRESENT, star or no star, so the grid cell is the same // box in both states and the page does not re-lay-itself out when the star list // lands. `.st-tilewrap` is a NEW rule in `starred/starred.css` scoped to itself; // `.home-tile`'s own rule in `index.css` is untouched (that file is A's).
{/* The SAME chip family as the database header and the rail row (`dbChipClass` → `.shell-db-chip` + its tone), so a database looks like itself everywhere. DESIGN.md 4: new elements join the existing rhythm, never a new control shape. The chosen mark wins when there is one; two letters otherwise, which is what the reference tiles show. ⛔ AND THOSE TWO BRANCHES CARRY A CONTRAST RULE — DO NOT "SIMPLIFY" THEM APART. `dbChipClass` returns a TONE class only when there IS an icon, and an icon is exactly when we draw a GLYPH. That matters because the tones are measured for a graphical object, not for text: white on `--lp-blue-deep` is 3.29:1 (the stylesheet publishes every pairing beside its declaration) — over the 3:1 bar a 16px glyph must clear, UNDER the 4.5:1 bar 12px letters must. The fallback branch has no icon, so it takes the BASE chip (`--lp-primary`, 7.71:1) and that is where the letters go. Forcing a tone onto the initials, or letters onto a toned chip, would put 12px text at 3.29:1 on screen. The two branches agree by construction; keep them agreeing. */} {tile.label} {/* ⚠ CONDITIONAL, because `ago` is EMPTY for a database nobody has opened — the state R10 makes visible for the first time. An unconditional span would render an empty line and push every tile in the row taller than the ones beside it; and `agoText` clamps a missing stamp to "Opened just now", so printing it would be the surface stating a fact nobody measured. */} {tile.ago ? {tile.ago} : null} {known ? ( toggle("database", tile.key, !dbStarred.has(tile.key))} /> ) : null}
))}
)) )} {/* ⭐ WAVE 24 item 13 (R10) — THE AUTOMATIONS SECTION. Same tiles, same rhythm. ⭐ WAVE 26 ITEM 18 (R14) — AND NO LONGER A STATUS DOT. This comment used to call `.auto-dot is-*` "the ONE status vocabulary this product has"; R14 deleted that vocabulary from the automation module entirely, so the sentence had to go with it rather than sit here describing a class nothing renders. Each tile carries the Loopable loop mark instead — the SAME mark on every automation, no status colour — and the run state has a home one click away, in the Builder's "Last run succeeded / failed" lines that R13 explicitly keeps. ⛔ THE STATE IS NOT MOVED INTO `title`/`aria-label` EITHER. Hanging "Last run failed" off a deliberately status-free mark just relocates the channel to somewhere invisible to the eye and announced to a screen reader — worse than deleting it, and contradicting the visual. `AutomationTile` drops `state` for the same reason: a field nothing reads is a fact the next reader will re-render. ⚠ A BUTTON, NOT A LINK. Opening an automation is a hash change PLUS a request to the surface for which one is selected — `AutomationSurface` owns `activeId` and this page cannot see it. An `` would land the reader on the surface with whatever was selected last, which is the wrong automation and looks like a bug. ⚠ EMPTY RENDERS NOTHING. `[]` is a real answer — no automations, or no grant (the frame turns a 403 into `[]`) — and a heading over nothing says "there should be something here", which is a different and false statement. */} {automations.length ? (
{/* ⛔ W34-T40's `done-when` names FOUR surfaces that must read Agents, and this was the one that did not: a hardcoded `"Automations"` that never consulted the module label. QA 2026-08-17. The rail row and the page heading both read `.label` off the registry entry, which is why they moved and this did not. ⚠ It takes the SHARED constant, not a second literal spelling it correctly today. The gate that was supposed to catch this pinned the string `Automations` and so REQUIRED the defect — [[a-gate-that-pins-a-spelling-not-a-claim]]. Re-typing "Agents" here would have left the next rename in exactly the same trap. */}

{AGENTS_MODULE_LABEL}

{automations.map((a) => (
{known ? ( toggle("agent", a.id, !agentStarred.has(a.id))} /> ) : null}
))}
) : null} {/* ⭐ WAVE 35 · W35-T16 (owner item 9) — THE STARRED VIEWS. Owner: *"remove [the important count] anywhere else but the View itself … Instead, let's add these Views under 'Home' and … under 'Starred' module as well."* So the badge leaves the rail and the flyout (A's W35-T07, E's W35-T43) and the VIEWS themselves arrive here. ⛔ IT IS THE SAME `Tile` COMPONENT `StarredPage` DRAWS, not a copy that agrees today. Item 11's complaint is that what is shown twice goes out of step, and two renderers for one object is how that starts. The chip, the two lines and the star are one piece of code. ⚠ EMPTY RENDERS NOTHING, like every other section here: no starred views is a real answer, and a heading over nothing says "there should be something here", which is a different and false statement. */} {starredViews.length ? (

Starred views

{starredViews.map((row) => ( toggle("view", row.id, false, row.database)} /> ) : undefined } onOpen={() => onOpenView(row.database, row.id)} /> ))}
) : null}
); } /** * The page the frame mounts. Its props are exactly what they were before wave 35. * * ⭐ THE ONE READ HOME MAKES, and it is deliberately the SAME hook Starred uses. Two copies of * "flip it, post it, take the server's answer, put it back if it was refused" is * [[one-question-two-normalizers]] with the divergence on screen: one surface optimistic, the * other reconciled, and the two disagreeing about what is starred. * ⚠ It reads AFTER PAINT (the hook's own `useEffect`, never `useLayoutEffect`), because * `GET /starred` scans a view bucket per database — C2 forbids a second store for the flag — and * was measured at 6.2 ms warm and 7,331 ms COLD on tenant #0 (mailbox E-2). */ export default function HomePage(props: HomeProps) { const { load: starLoad, starred, busy, counts, toggle } = useStarred(); return ( ); }