Skip to content

Client content sandbox D (do not merge) - #130

Draft
miloshroma wants to merge 24 commits into
mainfrom
sandbox-d
Draft

miloshroma wants to merge 24 commits into
mainfrom
sandbox-d

Conversation

@miloshroma

Copy link
Copy Markdown

No description provided.

@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
ideal-cms Ready Ready Preview Sep 17, 2026 7:07pm UTC

Request Review

- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants