Client content sandbox D (do not merge) - #130
Draft
miloshroma wants to merge 24 commits into
Draft
miloshroma wants to merge 24 commits into
miloshroma wants to merge 24 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- shopifyCarousel: renders several storefront products server-side, CSS-only rail - sidebarSection: the first two-column text layout in the block set - /topics/[slug]: a collection item renders from a route, not from a Page document - drop stale file headers that claimed the source was not yet applied - generalise comments that described one particular evaluation
…tion tab - a talk's body is a richText field, so interpolating it printed "[object Object]" on every unlocked item; render it through the shared RichText component - TalkGrid, TopicChips, ShopifyProduct and ShopifyCarousel never declared `section` or `id`, so they collected the section tab's settings and ignored them, rendering flush to the viewport edge; wrap each in SectionContainer - drop the outer margins those four carried to compensate for the missing container - shorten the carousel card: 150px cover, title clamped to two lines
Both routes rendered no header and no footer, so a viewer who opened an item had no navigation back to the site. Chrome is a per-Page-document relationship, which is why a hand-written route gets none unless it asks: these now read the header/footer slots off site settings, copying the blog route that already does exactly this. Topic pages move to /browse-topics/[slug], matching the segment a real content archive of this shape already publishes, and the two hardcoded links follow. Nothing else needed editing - nested docs, breadcrumbs, sitemap and static params all derive from slugs. The Topic doc comment loses a client-identifying catalogue count and two of their real topic names; the illustration works with invented ones. importMap.js is regenerated output: the new collections' rich-text fields pull three lexical feature clients the committed map was missing.
Each carousel card linked to a cart permalink, which lands the buyer on the store's cart page - one click short of the handoff the block exists to demonstrate. Every card now posts to a server action that creates the cart and redirects to the hosted checkout, which is what the single-product block already did. One module-scope action serves every card: the variant id travels in a hidden input, so nothing is captured in a closure. The shared transport stopped caching mutations. Every Storefront call is a POST, and Next only skips the Data Cache for a POST that carries no explicit cache config - the transport's blanket revalidate opted the cart mutation back in, keyed on the request body, so two buyers clicking the same variant inside the window were handed the same cart. Reads keep their ISR window; the mutation asks for no-store. Both Buy actions fall back to the cart permalink when the cart cannot be created, and log the cause. An unhandled failure would otherwise surface as an error overlay, which is a worse outcome than the cart page.
The talk and topic routes each carried a hand-rolled generateMetadata that returned a title and a description and nothing else, so the SEO tab's stored values only half reached the page: no canonical, no og:*, no twitter:*, no robots, and a stored meta image was dropped outright. Both routes now go through generateMeta, the same path a Page document uses, with their deliberate fallback chains resolved into the document handed to it rather than lost. A talk also emits its own structured data - an Article carrying the audio as an AudioObject with an ISO-8601 duration, breadcrumbs matching a page's, and Google's paywalled-content pattern: isAccessibleForFree with a hasPart naming the gated region by class, so a metered item reads as metered rather than as a truncated page. A gated talk deliberately omits contentUrl, because applyTier nulls the audio URL before the renderer sees it and JSON-LD ships to the browser like any other markup. No FAQPage: the questions field holds questions with no answers, and that markup requires an acceptedAnswer for each. The author comes from the first speaker name on a pull quote, which is indirect on purpose - the collection has no speaker field yet, and adding one means a migration. buildUrl and generateMeta gained the two new collections in their unions; both are shared with pages and posts, so the canonical output of an existing page is worth re-checking after this deploys.
Each pull quote carried a link reading "Listen at 2:55" whose href was the audio file with a #t= media fragment, and a comment asserting the browser would seek without JavaScript. It does not: a media fragment applies when the browser navigates to the media resource, not to an audio element already on the page. Measured in a browser - the click left currentTime at zero, and in an ordinary window it navigated away to the bare file. It is a client button now, seeking the player by an id the page hands it rather than by a tag selector, so a second player added later cannot be seeked by mistake. A rejected play() is swallowed on purpose: the seek has already landed, and a browser that blocks playback should leave the reader pressing play rather than facing an unhandled rejection. Also softens one field description that named the client's org type; the generated types carry that string, so they are regenerated with it.
The SEO tab on both collections rendered a Generate button that did nothing at all - no request, no error, no console output. A collection has to be registered in two places: the plugin's own collections array, which carries the field paths and the server extractor, and the client-side extractor registry, whose keys the generate hook looks up by collection slug. Neither knew about them, so the lookup returned undefined and the hook bailed before its fetch, surfacing only as a tooltip on a small icon beside the field label. Each collection gets a content extractor built from the shared rich-text flattener rather than a second one. A talk contributes its title, summary, teaser, body and derived takeaways and questions; the transcript is deliberately left out, because generation truncates its input and a transcript would evict everything else. A topic contributes its title and description. No schema change: the plugin appends an admin control, a provider and an endpoint, and stores nothing - so no migration and no regenerated types. Generation reads OPENAI_API_KEY, the same variable the translator plugin in this file already uses.
Both documents had a single "SEO" tab hanging below a long root-level form, where a Page document shows "Content | SEO" at the top. The content fields move into an unnamed tab, which in Payload is presentational: every field inside it is still stored at the top level, so this is layout only. Verified rather than assumed - the config builds and `generate:types` produces a byte-identical file, so there is no schema delta and no migration. That check matters here because the failure mode this file already warns about, DuplicateFieldName from a mis-nested SEO field group, typechecks clean and only appears when Payload starts.
Both Shopify blocks asked the editor for a product handle as free text - on a deal whose whole argument is that content should stop being a product catalogue, that is the wrong thing to ask a person to type. The handle field now renders a picker: browse the store, filter, and click a product with its cover and title. The stored value does not change. It is the same text field holding the same handle, so there is no schema delta - `generate:types` comes back byte-identical - no migration, and every handle already in a database keeps working. Products deliberately do not become a collection: they live in the store and the blocks resolve them at render time, which is the separation this demo exists to show. So the list comes from an admin-only endpoint that reuses the existing storefront transport, keeping the token on the server.
Every page and block added for this sandbox was written in hand-rolled
inline styles with pixel literals, so the result read as cramped and
unfinished next to the rest of the site: section headings at the same size
and weight as the paragraphs beside them, lists with their markers reset
away, a reading column that jumped 165px sideways on a click, and card
excerpts cut mid-word with no ellipsis. All of it now uses the app's own
type scale, colour tokens and shared components, so no file in this layer
carries a `style={{` object or a hex colour any more.
The product rail becomes a slider. Eight cards in a fixed-basis overflow
rail left three unreachable and sliced the fifth at the container edge;
cards are now whole fractions of the view, one to three by breakpoint,
with prev/next controls that disable at each end. The controls are not
server-rendered, so with no JavaScript the rail still scrolls and nothing
becomes unreachable.
Tier labels get one source of truth. The same document showed "Readable"
on the homepage and "Free" on a topic page, with a lock emoji on one and
not the other. The badge now states what the item requires rather than
what the current reader can see, which is what the field description
already promised, and it means a listing no longer reads the cookie and
can cache for everyone.
The locked notice stops looking like a leak and like a validation error.
It showed 300 characters of a 377-character body, cut mid-word, under
"Needs all-access. You are viewing as visitor" - now a shorter excerpt
ending in an ellipsis, with member-facing copy and the tier named the same
way the switch names it.
Behaviour is unchanged throughout: the gated body, audio and transcript
stay absent from the HTML rather than hidden, the section order stands,
the Buy button still creates a cart and redirects to hosted checkout, and
every class used was machine-checked against the compiled stylesheet.
The visual-editing plugin was never disabled for these two collections - neither appears in its skipCollections list - but nothing on either route ever reached it. Its enrichment is gated on the request carrying a draft flag (`shouldEnrich` reads it off the request context, where a beforeOperation hook puts `args.draft`), and both DALs called payload.find directly without one. The documents therefore came back with no edit paths stega-encoded into their text, so the preview had nothing to click while every Page document behaved normally. Both now read draftMode() and thread it through, the way getPageBySlug already did. A draft read skips the cache for the same reason that one does: an enriched document carries zero-width markers in its text, and caching those would serve them to ordinary visitors, while a shared key would let a draft overwrite the published entry. The talk listing also drops its published-only filter under draft, since the document an editor is previewing is exactly the one that filter hides. Topic has no versions, so `draft` there chooses no version - it is passed only to open that gate, which is worth knowing before someone removes it as dead. The tier switch moves to the bottom left: the plugin parks its toolbar in the bottom-right corner and the two overlapped inside the preview iframe.
One label map for tiers and kinds, read by the admin dropdown, the renderer and the view-as switch alike - the panel was title-casing stored values, so "student-qa" rendered "Student Qa" and the tier dropdown offered "all-access" while the badge beside it said "All Access". Field descriptions rewritten for the person editing. They carried our own notes: an ISO duration from someone else's structured data, a storage bucket and its signing query string, the word demo, and an argument about what a small team can be expected to review. The AI section and its three lists now open collapsed. Expanded, a transcript and two arrays pushed the SEO tab off the screen. Transcript segments are hidden outright - 8,000 rows an hour of audio that nobody can act on. The product handle is read-only and resolves to the product's real name, price and a link into the store. A mistyped handle saved clean, validated, and surfaced later as a section rendering nothing. Clear is gone: both fields are required, so clearing only produced a block to go back and fix. Preview env files are now ignored rather than merely untracked, which on a public repo is the difference between a guarantee and luck.
Replaces the teal/lime/Newsreader system with the green-on-white concept: eight colours, Poppins at two weights, and five new sections built to it. The semantic token layer kept its names, so the existing blocks re-skinned without being touched. New blocks: heroSpotlight, courseRail, membershipTiers, portraitFeature, bookOffer. Header and Footer renderers re-skinned in place. Three things the design system got wrong on the first pass, each silent: - tier 2 was written as `:root` rather than `@theme`, so no semantic colour utility emitted any CSS and nothing errored - display headings were set at weight 500; the concept sets all five of its big headings at 400 and spends 500 on the small type instead - eyebrow tracking was 0.14em, which the concept uses twice, against 0.1em twelve times Two traps in `cn()`, both of which delete a class and return valid CSS: - unconfigured tailwind-merge read `text-small` as a colour and dropped it whenever a colour class followed - it did not recognise `max-w-containerMaxW` or `px-containerBase` either, so an override passed to `Container` never replaced the default. The hero asked for `max-w-[1520px]` and measured 1180px in the browser with all four classes on the element. Both groups are now declared. The container is 1520px, which is what every slice of the concept is drawn at. Reading measure does not ride on it: a talk sets its own 720px and the Content block its own 520px, both inside the container. The frontend no longer follows the machine's colour scheme. `ThemeProvider` read `prefers-color-scheme` on mount and overwrote the server-rendered `data-theme="light"`, so the whole site rendered dark for any visitor whose OS was in dark mode. It also read `payload-theme` from localStorage, which is the admin panel's own key, so a dark admin darkened the public pages. Also: - the five blocks' SEO extractors were never wired into `extractPageText`, so a page built from them extracted to its title and nothing else - the block picker fetched five placeholders from a third-party host; real previews ship instead, and the fallback is drawn locally - `AbstractBackdrop` still painted the previous teal and lime behind Hero, CTA Band and Newsletter - the Copy AI Prompt button handed an assistant the old palette, the old fonts and the old type scale - the five hand-written stand-in interfaces are replaced by the generated types they had already drifted from - Section-tab controls read "Padding Y", "Padding X" and "Max Width" and carried no description; the link type radio had no label at all and rendered as "Type"
`base.css` says there is no bold in this design and that display and body are the same face. Both were false outside the five new sections. Twenty-four public components still asked for `font-semibold` or `font-bold`, including every variant of the shared Button and the shared Eyebrow, and the prose set `strong` at 600. The layout loaded a 600 weight specifically to serve them, with a comment explaining that it had to. They are all 500 now, and the 600 is no longer downloaded - a face that can render a bold is a face that will. Monospace was still the type of the public site's labels: the shared Eyebrow, the cards-grid label, chart axes, blog meta, filter chips and three prose rules. The design has one family, so those are Poppins at the concept's own 0.1em. Actual code keeps the monospace stack. Measured on the rendered page rather than inferred: zero elements above weight 500, zero in a monospace face. Two things the audit turned up: - the Portrait Feature description still promised a default photograph after the fallback was deleted, so the panel contradicted what the block renders - the footer shipped "© 2026 Cadence Labs, Inc." on every page, a placeholder brand belonging to nobody involved. The default now names what to replace
The design concept repeats a small set of components that this repo had never
pulled out. Seven different cards existed across the tree with five corner radii
and no shared parent, and three blocks had written their own class-joining helper
to escape the shared Button.
Ten primitives under src/components/ui/, all pure rendering:
- Button and IconButton. The concept has no pill and no black solid button:
every control is a 12px rectangle, and the two segmented ones are 7px. Five
tones cover all twenty of its buttons. The existing Button makes every variant
a pill, which is why HeaderActions, FeaturedCard and CourseRail's view-all each
rebuilt their own.
- ContentCard, variant course or featured. The concept draws one card twice: as a
rail card and as a smaller hero spotlight. It degrades where the data does,
so a feed with no rating drops the star cluster and keeps the date in its own
corner, and a feed with no price drops the price row.
- StarRating and RatingGlyph. The concept draws no partial star: it paints a
whole one in a lighter green, in three discrete fills. The rail's old cluster
used Math.round and two colours, so 4.5 rendered as five full stars.
- SectionMarker and Eyebrow, which are two primitives and not one. The sparkle
glyph goes with 15px sentence case; the tracked uppercase label never appears
beside it. The old Eyebrow always renders a pill, which the concept never does.
- Chip and ChipRow. The only black fill in the design is an active topic chip.
Its hover is shared by both states in the concept, which turns the active chip
mint while its text stays white, so the hover is gated to the inactive state
here rather than reproduced. Measuring how many chips fit is client state and
stays with the rail that consumes the row.
- Badge, PhotoPanel, ScrollReveal.
PhotoPanel carries a clip flag because one of the three panels cannot clip: its
book cover deliberately overhangs the edge, so the radius moves onto the image
and the overlay individually.
ScrollReveal ports the concept's 200ms polled sweep to an IntersectionObserver.
It renders content visible on the server and only applies the hidden state after
mount, so a crawler or a browser with JavaScript off never sees content that
only JavaScript can reveal. prefers-reduced-motion skips the animation outright,
which the concept does not do.
Two tokens the concept needs and the system lacked: rounded-inner, the radius it
uses five times for anything nested inside a card, and a control height used
twenty-three times by every button, chip and arrow.
None of the three cn-bypass helpers is carried forward. Measured against the
installed tailwind-merge 3.4.0, cn("text-eyebrow","text-primary") returns both
classes, so the bug those helpers describe is stock twMerge and not this repo's
cn. A shared component has to merge a caller's className and they cannot.
Both commerce blocks drew their own card. The carousel used object-contain on a muted panel with a full-width buy button under every item; the single-product block was the one section the design-system pass never reached, still carrying raw hex and pixel font sizes inline with no token anywhere in it. Both now render ContentCard. A product maps onto that card almost exactly: featured image, title, price, and compareAtPrice into the struck-through old price, which is the only place in this demo where a struck price is real rather than illustrative. Neither block passes a rating or a date, because a product carries neither and inventing them was not on offer. The buy button is an editor choice now rather than an assumption. It defaults off on the rail, where the design's cards are plain links and the whole card carries the href, and on for the single-product block, which exists to sell one thing. Turning it off there also drops the out-of-stock note: with no way to buy, stock is not something a reader can act on. Both buttons move to the new Button at tone primary. The old one renders every variant as a pill and this design has none. Shopify's CDN joins images.remotePatterns. Without it these covers had to pass unoptimized, which ships the original full-size asset and drops the whole image pipeline on the one section that is entirely photography. The rail stepped a whole clientWidth per arrow press; it now steps one card plus its gap, measured off the DOM because the card width is a fluid clamp with no breakpoint to hardcode against. Its gap also had to match the gap baked into that width formula, or the cards stop landing 3.28 to a row the way the design assumes. The rails stay on CSS scroll-snap for now. A shared Embla rail replaces all three.
The talk grid drew its own card: a wrapping auto-fill grid of title, teaser, a kind and duration meta line, and a tier badge with a lock glyph. None of that is in the design. It renders ContentCard now, which is what makes the real content look like the concept rather than only the sections built from it. A talk gains an optional coverImage, because the card is image-led and a talk had nothing to put there. Optional, because sixteen already exist without one. What maps where, given a talk has no rating and no price: the teaser becomes the description, publishedAt the date, and the required tier takes the green price slot, so a gated talk reads "Premium" where a book reads a real currency. Nothing passes a rating or a struck-through price, because there is no rating anywhere in the client's catalogue and no content item in it carries a price. Two things this turned up that were not in the block. LISTING_SELECT did not request coverImage. A field missing from that list is invisible rather than broken: the document comes back without it and the card silently renders its empty state, whatever an editor set. It would have been the card's whole top half, on every listing, with nothing erroring anywhere. "Show kind" had nowhere to render. The concept's course card has no eyebrow slot and the featured variant's would have dropped the description and shrunk the card to a third of its width. But the concept's meta row is justify-between with the stars on the left, and a talk is never rated, so on this feed that slot is always empty and the date sits alone against the edge. The kind takes it. A rating still wins it wherever one exists, so nothing changes for a feed that has them. The grid also stops being a CSS grid. ContentCard's course width is a calc against 100% of its container, which resolves to the row in a flex line and to the cell in a grid track - every card would have collapsed to its 260px floor inside an oversized cell. It is a wrapping flex row at the gap the formula assumes.
The rail is the centre of the page and it showed hand-typed strings. Its chips carried a checkbox an editor ticked to mark one "selected", and clicking a chip navigated away. The design does neither: its chips filter the rail in place, in memory, with no navigation and no refetch. A source select now chooses between the typed rows, which stay the default so nothing existing breaks, and real Talk documents. In talks mode the server fetches once, derives the chip list from the topics actually present on the talks it got back, and hands a flat array to the client, which holds the selected topic and narrows the list. Deriving the chips from the fetched set rather than from the topic collection matters here: 24 of the 28 topics hold exactly one talk, and a chip that filters to nothing is worse than no chip at all. The card is ContentCard now. CourseCard, CourseCardBody, RatingCluster and the withTypeUtility helper are all gone. A talk has no rating and no price, so the tier takes the price slot and the kind takes the slot the stars would have had. The chip row measures itself the way the design does, and keeps both traps that make that work: a chip's last non-zero width is cached, because a hidden chip measures zero, and the count is re-taken after fonts load, which is what stops the first paint being wrong. Two fidelity fixes. The arrows stepped a whole viewport width; they step one card plus the gap now, over the design's own 380ms ease, on requestAnimationFrame rather than a 16ms interval. And they were hidden below a tablet breakpoint the design does not have, so they show at every width. Reduced motion is honoured in both places the design ignores it: the arrow tween jumps straight to its target, and the entrance stagger does not run.
Seven new columns across the block tables and the talk collection: a cover image on a talk, a source and its two companions on the course rail, a layout on the talk grid, and a buy-button toggle on both store blocks. The up direction is purely additive - 32 ADD COLUMN, 10 CREATE TYPE, and zero drops of any kind. The only constraint change relaxes NOT NULL on the rail's typed label and title, which became conditional the moment a section could be fed from documents instead. All 32 DROP COLUMN statements are in the down direction, where they belong. Generated types regenerated with the env-gated plugins' variables supplied, and the diff read before staging: 71 insertions, zero deletions. The import map came back unchanged. Both of those are checked rather than assumed, because codegen run without those variables deletes a disabled plugin's output silently and commits it with everything else. The three local placeholder interfaces the blocks carried while the generated types lagged are gone, and two comments that went stale in the same pass are corrected: the talk listing does now request the cover image, and the hand-written talk shape in the rail is not a regeneration gap - it is the narrow shape a select query actually returns, and should stay that way.
DisplayHeading set `text-primary` as its base colour. That was right for the palette this design system replaced, where primary was a pale cream on dark. It is the brand green now, so every heading that renders through this component - eleven call sites, including every block using SectionHeader - came out green against a white page. Only the guidance rail looked right, and only because it renders its own h2. The heading takes `text-foreground` now, which resolves to the page ink per theme zone: black on a light section, white on a dark one. That is what the concept draws. SectionHeader's eyebrow was still the old pill chip, which the concept never uses. It is the concept's own marker now - the four-point sparkle beside sentence-case text - so a section header built from shared parts matches one written by hand. Its tone names the backdrop rather than the ink, which is how the marker already read it, so the CTA banner's variant map is corrected in the same pass: its accent and dark variants sit on dark grounds and take light text. Found by looking at the deployed page, not by reading the diff.
A course card sized itself with `clamp(260px, calc((100% - 2 * gap) / 3.28), 460px)`. That is only correct when the card is a direct child of the rail, which it is in CourseRail and is not anywhere else: inside the `<li>` that TalkGrid and the store rail wrap it in, `100%` resolved against a shrink-to-fit parent, so each card took the width of its own text. The talks archive rendered sixteen 1,200px cards in one column at every desktop width, and the store rail's eight cards came out 377px to 886px wide with holes between them. The width belongs to the track. Rails are now `grid-auto-flow: column` with `grid-auto-columns` carrying the formula, so a track is exactly as wide as it is told to be whatever it contains; the wrapped listing is a real CSS grid at 1/2/3 columns. The card fills its box. Same pass, the two rails move onto one `useCardRail` hook - measurement, the one-card 380ms easeInOutQuad step and the end-state arrows were duplicated line-for-line - and it adds pointer drag, which is what makes the store rail read as a slider rather than as a scroll area with arrows bolted on. Listeners go on `window`, not the rail, because a fast drag leaves the rail's box within the threshold and would strand them. Embla is still not installable here: `bun add` re-resolves the workspace and 404s on the private plugin, and the registry token is not on this machine.
The pgvector search was real and working - OpenAI text-embedding-3-small into a `document_embeddings` table, cosine distance, a 0.75 ceiling - and covered only Pages and Posts. On an archive site that indexes the chrome and none of the content: a query for "letting go" returned one Page and no teachings. Talk and Topic are indexed now. A talk embeds its title, teaser, AI summary, takeaways, questions and body; the transcript is deliberately left out, because one vector for 60,000 characters averages a talk's subject away into whatever the speaker said most often. A topic is indexed as well as a talk because on this archive a topic page is a destination in its own right. The hooks come from one factory rather than a third and fourth hand-written pair. The two existing copies had already drifted - one honours `skipEmbedding` and the other does not - and both have a defect the factory fixes: they write on publish and delete only on hard delete, while the query applies no status filter, so an unpublished document stayed findable forever. On a paywalled collection that matters more than staleness. Moving Page and Post onto the factory is left for its own change. The enum migration is hand-written and its `down` is a deliberate no-op: Postgres has no ALTER TYPE ... DROP VALUE, and a widened enum with no rows using the new values is harmless where the table rewrite that reverses it is not. /search itself was the last page still on raw `gray-*` Tailwind, its group heading was a two-arm ternary whose else-branch said "Pages" (so every talk would have rendered under it), and the header's search button had been inert since it was drawn. Results are the site's own card in a grid now, and the page asks for a sentence rather than a keyword, because a reader typing one word into a box labelled Search concludes the search is bad when it was the prompt that was wrong. Existing documents carry no embeddings: the hook only fires on save, so the 16 talks and 28 topics need one re-save each.
The well is right on the concept's own feed, where every card is a course with artwork, and wrong on this archive: a talk's cover is optional and a topic has no image at all, so the search results came out as a grid of empty pale-green boxes with a title underneath each. Search also splits its two halves: the asking half stays a reading column, the results take the site container. One width for both either cramps the grid to three narrow cards or stretches the input to 1,520px.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.