Skip to content

overhaul: 05 app shell - #19

Open
CS-5 wants to merge 3 commits into
overhaul/04-content-modelfrom
overhaul/05-app-shell
Open

overhaul: 05 app shell#19
CS-5 wants to merge 3 commits into
overhaul/04-content-modelfrom
overhaul/05-app-shell

Conversation

@CS-5

@CS-5 CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Layer 5 of the overhaul stack, on overhaul/04-content-model. plan/05-app-shell.md.

The chrome every page shares. This is the first layer where the site looks like the site — the preview URL now shows real header, footer, and metadata.

The part that matters most

SEO correctness is in the type signature, not in a review checklist. BaseLayout requires title and description, so a page that omits one does not compile:

Property 'description' is missing in type '{ children: any; title: string; }'
but required in type 'Props'.

That's the mechanism that stops Phase 07 from shipping the eight pages legacy shipped with missing metadata.

What's here

Seo.astro — title (templated, bare site name on the homepage), description, canonical, the full Open Graph set with image dimensions and alt, Twitter summary_large_image, theme-color. No keywords meta: legacy carried 22 terms for a signal that stopped mattering years ago.

src/lib/jsonld.ts — typed builders, serialized through a tiny JsonLd.astro. NGO (the specific type for a nonprofit, not bare Organization) site-wide, WebSite on the homepage, breadcrumbs() ready for Phases 07–08.

Navbar.astro per D26 — the real lockup, full-width on desktop and the square mark on mobile, never a rebuilt gear plus the name in Inter. Sticky, condensing after scroll via a scroll-driven animation behind @supports and prefers-reduced-motion. Programs is a real link to /programs with a dropdown in addition, so touch and no-JS get a destination.

Footer.astro — recessed card band with the lockup, three link columns, socials, and the engineering title block at the bottom where a drawing sheet puts its identity strip.

404.astro — branded and actually useful (the four places people were most likely headed), noindexed.

Icons + manifestfavicon.ico carried over, icon.svg from the square mark, 180/192/512 PNGs rendered from it with sharp, and a web manifest.

/ and /styleguide now render through the shell.

Verified in a browser, not by eye

Check Result
Skip link is the first tab stop, visible on focus
Programs panel opens on keyboard focus; Tab enters it ✅ (opacity: 1, next stop is "FLL")
Mobile toggle target size 44×44
aria-expanded flips; sheet shows; body scroll locks
Esc closes the sheet and returns focus
Landmarks 1 header, 1 main, 1 footer, 3 labeled navs
Head output title, description, canonical, 8 OG tags, 4 Twitter tags, theme-color, 2 font preloads, 4 icon links
JSON-LD NGO site-wide + WebSite on home

pnpm check && pnpm build green.

One acceptance item I could not complete

The brief asks to paste the JSON-LD into Google's Rich Results test once. search.google.com and validator.schema.org are both blocked from this environment, so that manual check is outstanding. Phase 10 already tasks validating every distinct JSON-LD shape, so it lands there — flagging it rather than quietly ticking the box.

Deviations

  • sharp is now a direct dependency. The first real <Image> use made astro:assets require it; pnpm-workspace.yaml's allowBuilds: sharp already anticipated exactly this.
  • exactOptionalPropertyTypes forced a signature choice. A layout that forwards optional props passes an explicit undefined, which that flag treats as distinct from an absent prop. Receiving props are declared ?: T | undefined rather than filtering props at every call site — worth knowing before writing the next layout.
  • The OG image is legacy's opengraph-image.png, moved to public/og/default.png. Phase 10 builds the curated per-section set; inventing a template now is work Phase 10 would redo.
  • Footer content grew slightly beyond legacy's inventory. Legacy had two columns and pointed Donate at /wiki/donations; this points at the real /donate page and adds a Programs column (FLL, FRC, Calendar) — with the mobile sheet closed, the footer is otherwise the only place a program link appears. The 501(c)(3) line and everything else is verbatim.
  • The apple-touch icon is flattened onto #262626 because iOS ignores transparency and would composite it on black.

Generated by Claude Code

This was referenced Aug 28, 2026

CS-5 commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

/simplify pass — quality review (reuse · simplification · efficiency · altitude)

Quality only — no correctness findings here; a separate /code-review pass follows. This is the largest crop in the stack, which is expected: it's the first PR where components compose rather than stand alone, so duplication becomes visible for the first time.

First, one thing the diffstat gets wrong. src/pages/styleguide.astro shows 1023 changed lines, which looks like PR #19 rewriting what #16/#17 just built. git diff -w reduces it to 17 insertions / 22 deletions — the churn is re-indentation from replacing the hand-rolled <html> skeleton with BaseLayout, plus dropping the page's own <main> now that the layout owns the landmark. #16/#17 built the right thing. No finding, but worth knowing before reviewing that file.


1. The site's route inventory is declared four separate times

src/components/ui/Navbar.astro:32-37 (programLinks), :63-127 (desktop anchors), :156-161 (mobile array), src/components/ui/Footer.astro:18-43 (sections), src/pages/404.astro:14-27

src/data/site.ts:67-81 already exports programs with name, shortName and href. Navbar reads it correctly on line 33. Footer re-types the same values by hand:

  {
    title: "Programs",
    links: [
      { label: "FIRST LEGO League", href: "/programs/fll" },
      { label: "FIRST Robotics Competition", href: "/programs/frc" },

— which is exactly programs.fll.name / .href and programs.frc.name / .href. /calendar/sc2 appears as a literal in three places.

The four lists already disagree: Donate is a desktop nav link and a mobile bottom button but not in the mobile list; Calendar is in the mobile list but only in the desktop dropdown. That split may well be intentional per DESIGN.md §5 — but nothing in the code records it, so a reader can't distinguish a deliberate choice from an omission, and neither can a reviewer.

plan/05-app-shell.md:47 for the Footer: "Reference legacy/src/components/Footer.tsx … for content inventory: nav links, social icons, contact info, legal line. All data from src/data/site.ts."

Cost: AGENTS.md's "No hardcoded constants" is what makes a URL change a one-file edit. Today, adding a program or renaming /calendar/sc2 means editing two components in three spots plus .lycheeignore, and hoping the Footer's copy of the program names still matches. D17 promises "All current URLs preserved exactly" and the link check can't see a stale label. Phases 07/08 add ~13 more routes onto this shape.

Fix: one nav structure beside programs in src/data/site.ts (or src/data/nav.ts) with the primary items, the programs submenu, and a per-item note of which surfaces it appears on — so the desktop/mobile split becomes data instead of a discrepancy. Navbar (both viewports), Footer and 404 all project from it. The desktop block then becomes the same .map() the mobile sheet already uses.

1b. …and the nav-link class string is pasted four times

src/components/ui/Navbar.astro:66 (verbatim again at :114, :121, and with two extra classes at :79)

        class="text-small text-foreground hover:text-primary-bright aria-[current]:text-primary-bright rounded-md px-3 py-2 font-medium no-underline"

100 characters encoding the nav item's typography, hover, current-page state and hit area, four times. A §5 nav change is four edits with nothing to fail if one is missed.

Fix: hoist to one const navLinkClass in the frontmatter, or compose from buttonVariants (Button.variants.ts) — a ghost/sm composition is roughly what this string approximates.


2. DESIGN.md §8's link contract has no implementation home, so every link restates it — and drops half of it

Navbar.astro:66, 79, 101, 114, 121, 165; Footer.astro:66, 77, 96; 404.astro:48; BaseLayout.astro:70 — 12 hand-written no-underline overrides.

global.css:178-182 styles the bare a element for the in-prose case only, so every chrome link opts out by hand. In doing so it implements neither half of the rest of §8:

in-prose links primary-colored and underlined; UI links may drop underline at rest but underline on hover/focus. External links: icon at 0.8em + rel="noopener".

No chrome link underlines on hover — they change color instead. And the Footer's external links (Wishlist, the three socials) carry rel="noopener" with no external-link icon.

Cost: two documented visual rules are unimplemented with nowhere to fix them once. A ui-link change today is a 12-site edit, and Phases 06–08 will copy the incantation into every new page. That's how the doc quietly stops describing the site — DESIGN.md §11: "when code and the doc disagree, the doc wins."

Fix: two utilities in global.css beside the existing motif utilities — ui-link (no underline at rest, underline on hover/focus, aria-[current] state) and prose-link — and scope the bare-a rule to prose containers with :where() so chrome needs no opt-out at all. An external boolean on the link utility, or a small ExternalLink in ui/, gives the §8 icon one owner.


3. The Programs dropdown can't satisfy the Esc requirement its own brief still carries

src/components/ui/Navbar.astro:76-110 (panel), :203-237 (the script, which handles only the mobile sheet)

The disclosure is pure :hover / group-focus-within CSS with no dismissal path. plan/05-app-shell.md:40, untouched by this PR, still specifies:

Programs dropdown: CSS :focus-within/popover-attribute disclosure … Keyboard reachable, Esc closes.

DESIGN.md §9: "Keyboard: everything operable; skip link first in DOM; aria-current="page" in nav; menus close on Esc."

The acceptance line (plan/05-app-shell.md:59) was rewritten to "the Programs panel opens on :focus-within and Tab moves into its links" and marked [x], scoping "Esc" to the mobile toggle only — and the omission isn't in the PR's "Notes and deviations". Same pattern as PR #17's criterion 55; flagging it here as a habit worth breaking now rather than at Phase 09.

Cost: hover/focus-revealed content with no dismissal also misses WCAG 2.2 SC 1.4.13, against a stated bar of "WCAG 2.2 AA minimum everywhere" and a Phase 09 Lighthouse Accessibility budget of 100. Bolting Esc onto :focus-within later means a keydown listener plus class-based open state plus focus bookkeeping — replacing the mechanism inside the chrome every page ships, after ~30 pages depend on it.

Fix: take the alternative the brief already names — the native popover attribute with popovertarget on the Programs chevron. Esc, light-dismiss and top-layer all come from the platform (rung 1 of the primitives README's interactivity ladder), the /programs link keeps its real destination, and the CSS shrinks. Then leave criterion 59 measuring the requirement rather than the implementation.


4. The menu script maintains four parallel representations of one boolean, three of which CSS can derive

src/components/ui/Navbar.astro:203-236

    const setOpen = (open: boolean) => {
      toggle.setAttribute("aria-expanded", String(open));
      toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
      sheet.hidden = !open;
      sheet.classList.toggle("hidden", !open);
      iconOpen?.classList.toggle("hidden", open);
      iconClose?.classList.toggle("hidden", !open);
      document.body.style.overflow = open ? "hidden" : "";
    };

sheet.hidden and the hidden utility class (line 145, plus the hidden attribute on line 147) are two encodings of the same display state fighting each other — Tailwind's preflight already gives [hidden] display: none, so the class is redundant, and any future flex/grid on the sheet will silently beat the attribute. The icon swap and the scroll lock are pure presentation driven from JS, so open state is spread across five DOM mutations and two querySelector handles that must all stay in sync.

Fix: let aria-expanded be the single state and have CSS read it:

[data-menu-toggle][aria-expanded="true"] [data-menu-icon-open],
[data-menu-toggle][aria-expanded="false"] [data-menu-icon-close] { display: none; }
body:has([data-menu-sheet]:not([hidden])) { overflow: hidden; }

setOpen reduces to the two setAttribute calls plus sheet.hidden = !open; the two icon querySelectors, the hidden class and the inline body.style.overflow write all go away.


5. The sticky header animates height on a scroll timeline — layout on every scroll frame

src/components/ui/Navbar.astro:186-200

      [data-header-lockup] {
        animation: condense-lockup linear both;
        animation-timeline: scroll(root block);
        animation-range: 0 120px;
      }
...
  @keyframes condense-lockup {
    to {
      height: 2rem;
    }
  }

height is not a compositor property. Through the first 120 px of every scroll the lockup's box changes each frame, relayouting the in-flow sticky header and reflowing the content below — main-thread work during the most jank-sensitive interaction on the page. DESIGN.md §6 is explicit: "Only opacity and transform animate."

Fix: animate scale with transform-origin: left center (same visual condense, compositor-only) and give the header a fixed height so nothing below it moves. The @supports / prefers-reduced-motion guards can stay exactly as they are — those are well done.


6. The header logo is lazy-loaded on every page

src/components/ui/Navbar.astro:54-59

Astro's <Image> defaults to loading="lazy" decoding="async" unless priority is passed (resolvedOptions.loading ??= "lazy"). Confirmed in built HTML:

``&lt;img src="/_astro/logo-color-full.HAyTwbyd_ZzTcW5.svg" alt data-header-lockup="true" … loading="lazy" decoding="async" width="3069" height="1000" class="hidden h-10 w-auto …"&gt;``

Cost: the top-of-viewport brand mark on every page is invisible to the preload scanner and only requested after layout, at low priority. It's the first thing a visitor looks at and one of two LCP candidates on a text-light shell. Small bytes (11.6 KB), badly scheduled.

Fix: priority on the header instance. Leave Footer.astro:56 lazy — it's correctly below the fold and shares the same fingerprinted URL.


7. Org facts and the brand color re-typed outside site.ts and the token block

public/site.webmanifest:2-8, with src/components/Seo.astro:78 and src/components/ui/Footer.astro:58

  "name": "South Central STEM Collective",
  "short_name": "SC2",
  "description": "Hands-on STEM and FIRST robotics for students in and around Franklin County, PA.",
  "background_color": "#262626",
  "theme_color": "#FACC15",

name/short_name are site.name/site.shortName (src/data/site.ts:10-12). The description is a third distinct one-line blurb (alongside site.description and the Footer's). The two colors are --color-background/--color-primary, and #FACC15 appears once more at Seo.astro:78 (<meta content="#FACC15" name="theme-color" />) and again in styleguide.astro — four hardcoded copies of the brand yellow. The Footer also inlines the 501(c)(3) sentence as prose.

plan/05-app-shell.md:34 asked for "site.webmanifest (name, short_name SC2, theme/background colors from tokens, icons 192/512)".

Also: theme-color as a static literal can't follow data-theme, so an FRC page advertises yellow browser chrome on a green page — and fixing that later means threading a branch through Seo rather than reading the theme's token.

Fix: make the manifest a generated route (src/pages/site.webmanifest.ts returning JSON built from site), and move the brand color and the 501(c)(3) legal line into site.ts so Seo.astro and the manifest read one constant.


8. Three smaller reuse items

Footer.astro:35-42 vs :45-49 — the three social links are declared twice in the same file. sections[2].links and socials are the same three entries differing only by an icon key. Adding Instagram or dropping GitHub means editing both, nine lines apart, or the text column and the icon row disagree. Declare socials first and set links: socials — the text list ignores the extra key. The mail entry belongs in that array too, rather than as a hand-copied <li> at :77.

Footer.astro:90 — the column heading re-implements CardTitle.

              <h2 class="text-h4 text-foreground font-sans font-semibold">{section.title}</h2>
              <hr class="title-rule mt-2" />

CardTitle.astro:19-22 is the same class list plus exactly this <hr class="title-rule"> behind its rule prop. Two definitions of the §8 "title + 32px accent rule" pairing, so a change fixes cards and misses the footer. Use <CardTitle as="h2" rule>.

404.astro:35 — the §5 container is a class string repeated at every call site. mx-auto max-w-6xl px-4 md:px-6 also appears at index.astro:16, styleguide.astro:176, Footer.astro:53 and (with flex) Navbar.astro:48. global.css already exists for this, holding @utility section-y and @utility measure under the comment "Signature motifs … as utilities, so pages cannot reinvent them" — add @utility container-page beside them before Phases 06–08 add five more copies.


9. knip.jsonc's "future seam" entries have started going stale — in this PR

knip.jsonc:6-18

Five modules are registered as knip entry points to keep them from reporting as dead. Three are now genuinely reachable from src/pages/**: src/lib/cn.ts (every primitive imports it as of #17), and src/styles/fonts.ts + src/data/site.ts (both imported by BaseLayout/Seo/Navbar/Footer in this PR). Only ProgramLayout.astro is truly unreferenced, and jsonld.ts's entry is really covering one unused export, breadcrumbs.

Cost: an entry file is a root, so knip stops reporting unused exports in it too — meaning the two most central modules in the codebase, site.ts and cn.ts, are now permanently exempt from the "exports": "error" rule the config sets. A retired URL or a dead helper in site.ts will never be flagged. The PR's own note says "Each carries a comment saying so, so the entries can be removed when they are genuinely referenced" — this is the PR that made them referenced.

Fix: delete the cn.ts, fonts.ts and site.ts entries now (their real importers cover them), and replace the jsonld.ts entry with a /** @public */ tag on breadcrumbs so the suppression is one export wide instead of one module wide. Keep ProgramLayout.astro and give it the same expiry treatment .lycheeignore got.


10. Minor: zero-argument factories for two constants

src/lib/jsonld.ts:23,44

export const organization = (): JsonLdObject => ({});
export const webSite = (): JsonLdObject => ({});

Both depend only on site. The function form implies the value depends on something — a page, a date — and makes call sites read as if work happens there. breadcrumbs(trail) is a real function, and the contrast is what makes these two read wrong. Plain const organization: JsonLdObject = { … }; breadcrumbs stays a function.


Checked and cleared

  • .lycheeignore + tools/checks/stale-link-ignores.mjs + the new Phase 07 acceptance box (plan/07-pages.md:69) is the model way to land a temporary suppression — scoped to ten named routes, guarded by CI against outliving its purpose, and with a deletion date written into the plan. That's the pattern findings 9 and PR overhaul: 03 primitives #17's cn.ts finding should be held to. Cost-wise it's one existsSync per entry (11 entries) and the lychee step is --offline, so neither adds network time to CI.
  • The mobile sheet script attaches three listeners and no scroll/resize handler; the Programs dropdown is CSS-only (finding 3 is about dismissal, not about JS creeping in). Zero JS files in the built output apart from the ~700-byte inlined toggle.
  • The styleguide.astro diff is whitespace, per the note at the top.

Generated by Claude Code

@CS-5 CS-5 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness review of the app shell. I built the branch at 1b55703 (linking sharp in from the store so the image step completed) and read the emitted HTML/CSS rather than the source alone, which is how the first finding turned up.

9 findings, most of them in Navbar.astro's mobile sheet.

The one worth acting on before merge: Icon.astro has no rest spread, so data-menu-icon-open / data-menu-icon-close never reach the DOM — the menu button's hamburger→X swap silently never happens. It doesn't fail typecheck because TypeScript doesn't check hyphenated JSX attribute names, and it doesn't fail at runtime because of the ?.. Verified in dist/index.html. This is a good argument for the primitives that get driven by script to spread ...rest.

Two more sheet bugs in the same area: the backdrop click handler is unreachable (<nav class="h-full"> fills the fixed inset-0 sheet, so event.target === sheet is never true), and the body scroll lock leaks if the viewport crosses md while the sheet is open — the sheet and its toggle both vanish via md:hidden and leave the page unscrollable.

The rest are smaller: no inert on the page behind the open sheet, isCurrent vs inSection disagreeing between the two navs, the stale-link guard silently skipping entries it can't parse, min-h-dvh without a flex column, the maskable manifest icon reusing the unpadded mark (measured: 5.9% padding, needs ~20%), and the og:image dimensions block never firing for the default card.

Nothing here is architectural — the layout/SEO shape is good, and putting description in the type signature is the right call. Verified separately and found clean: the .lycheeignore list is exactly the 10 root-relative links in dist that don't resolve (no more, no less), font preload URLs match the @font-face url()s hash-for-hash, canonical/noindex are correct on all three pages, the OG image is a proper 1200×630, and astro check is at 0 errors.


Generated by Claude Code

Comment thread src/components/ui/Navbar.astro Outdated
Comment thread src/components/ui/Navbar.astro Outdated
Comment thread src/components/ui/Navbar.astro Outdated
Comment thread src/components/ui/Navbar.astro Outdated
Comment thread src/components/ui/Navbar.astro Outdated
Comment thread tools/checks/stale-link-ignores.mjs Outdated
Comment thread public/site.webmanifest Outdated
Comment thread src/layouts/BaseLayout.astro Outdated
Comment thread src/components/Seo.astro
@CS-5
CS-5 force-pushed the overhaul/05-app-shell branch from b3f088b to 485d52f Compare August 28, 2026 15:23
@CS-5
CS-5 force-pushed the overhaul/05-app-shell branch from 485d52f to 9e9e5ab Compare August 28, 2026 15:38
CS-5 and others added 3 commits August 28, 2026 16:17
The chrome every page shares, with SEO correctness built into the type
signature rather than left to each page: BaseLayout requires title and
description, so omitting either is a compile error instead of something a
reviewer has to catch.

Seo emits title, description, canonical, the full Open Graph set with image
dimensions, and a Twitter card. No keywords meta — legacy carried 22 terms
for a signal search engines dropped years ago.

jsonld.ts holds typed builders: NGO (the specific type for a nonprofit, not
bare Organization) on every page, WebSite on the homepage, and breadcrumbs
ready for the nested pages in Phases 07-08.

Navbar follows D26: the real lockup, full-width on desktop and the square mark
on mobile, never a rebuilt gear plus the name in Inter. Programs is a real link
to /programs with a dropdown in addition, so touch and no-JS both get a
destination — verified that the panel opens on keyboard focus and Tab moves
into it. The mobile sheet is full-height with 44px targets, locks body scroll,
and closes on Esc with focus returned. The only script is the ~25-line toggle.

Footer is a recessed card band carrying the lockup and the engineering title
block. It points Donate at the real /donate page rather than legacy's
/wiki/donations, and adds a Programs column — with the mobile sheet closed, the
footer is otherwise the only place a program link appears.

sharp becomes a direct dependency: the first real <Image> use made astro:assets
need it, which allowBuilds already anticipated. The icon set is rendered from
the square mark rather than committed by hand; the apple-touch icon is
flattened onto the brand ground because iOS ignores transparency.

exactOptionalPropertyTypes forced one signature choice worth knowing: a layout
that forwards optional props passes explicit undefined, which that flag treats
as distinct from an absent prop, so the receiving props are declared
`?: T | undefined` instead of filtering props at every call site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
The app shell links to the full site's routes, but Phase 07 builds them, so
the offline link check failed on every page carrying the header and footer:
65 errors across ten routes. That is a correct finding about an unavoidable
ordering problem, not a tooling fault.

.lycheeignore excludes exactly those ten routes and nothing else, so the gate
stays live for assets, anchors, and any link outside the list — verified by
seeding a bogus href, which still fails the check.

An exclusion list like this is a trap once its pages land: the entry silently
keeps that page out of the link check forever. tools/checks/stale-link-ignores.mjs
fails CI if an excluded route now exists in dist, so the list can only shrink.
Phase 07's acceptance criteria now require the file to be gone.

Verified locally with lychee 0.24.2 using the workflow's exact flags: 0 errors,
and the guard correctly fails when a landed route is left excluded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRfxMh7FLjQtDbb1BEsCbR
Four things in the mobile menu did not work. `Icon.astro` has no rest spread, so
`data-menu-icon-open`/`-close` never reached the DOM and the hamburger never
became an X — the `?.` swallowed the nulls and TypeScript does not check
hyphenated JSX attribute names. Backdrop close was unreachable: the sheet's
`<nav class="h-full">` is exactly the sheet's box, so `event.target === sheet`
could never be true. Crossing `md` with the sheet open left the page
scroll-locked and unscrollable with no visible control, since both the sheet and
its toggle are `md:hidden`. And nothing made the page behind the opaque sheet
inert, so Tab past the last item walked every invisible link in `main` and
`footer`.

The sheet now keeps one piece of state — `aria-expanded` — with CSS deriving the
icon swap and the scroll lock from it, a `matchMedia` listener closing it at
`md`, and `inert` on everything in the shell that is not the toggle. Verified in
a real browser: icon swap, Esc, scroll lock, inert (only the close button stays
focusable), and the breakpoint close all behave.

The Programs panel was a `:focus-within` disclosure with no dismissal path, which
could not meet this phase's own "Esc closes" criterion (§9) or WCAG 2.2 SC
1.4.13 — and that criterion had been rewritten to describe what shipped. It is a
native `popover` now: Esc, light-dismiss, top-layer and the `expanded` state come
from the platform with no script, and the criterion is restored.

The sticky header animated `height` on a scroll timeline, relayouting itself and
reflowing the page on every frame through the first 120px, against §6's "only
`opacity` and `transform` animate" — it animates `scale`. The header logo was
`loading="lazy"`, invisible to the preload scanner despite being an LCP
candidate; it is `priority`. `min-h-dvh` on a block `<body>` stretched nothing,
leaving a lighter strip below the footer band on tall viewports; the body is a
flex column and `main` absorbs the slack (measured: 0px gap at 1600px tall).

`stale-link-ignores.mjs` silently dropped any entry it could not parse, so a
malformed exclusion escaped the staleness guard forever while lychee still
applied it — exactly the permanent blind spot it exists to prevent. It fails loud
now. The `og:image:width`/`height` block was dead for every page, since the
default `ogImage` is a string; the default card's dimensions travel with its path
in `site.ts`. The manifest's maskable entry reused the unpadded 512, whose
artwork spans ~88% of its box, so a launcher mask clipped the gear teeth — a
padded render at 72% is a separate file.

Quality: the route inventory was declared four times across two components and
the 404 page, already diverged, with the Footer re-typing program names
`site.ts` already held; it is one `nav` structure there, and the
desktop/sheet split is a `surfaces` field rather than a discrepancy. DESIGN.md
§8's link contract had no implementation home — twelve call sites wrote
`no-underline` and no chrome link underlined on hover; `ui-link` and
`external-link` are utilities, as is §5's container rule, which was a class
string at five sites. The Footer declared its socials twice and re-implemented
`CardTitle`. `site.webmanifest` is a generated route reading `site.ts` instead of
restating the org name, a third description and both brand colours.
`organization`/`webSite` are constants, not zero-argument factories.

Dropping the `cn.ts`, `fonts.ts` and `site.ts` knip entries — all genuinely
referenced as of this PR — restored export-level checking in the two most central
modules; `jsonld.ts`'s entry became `@public` on the one unused export.

Also fixed a regression this PR nearly introduced: the first draft toggled the
menu icons from a scoped `<style>`, which Astro rewrites to demand this
component's scope attribute — the same failure the Carousel had, since
`Icon.astro` renders its own root. `:global()` on the icon side, verified in the
built CSS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX5PrKuYNRLVxiEj3eejhs
@CS-5
CS-5 force-pushed the overhaul/05-app-shell branch from 9e9e5ab to 1e0d0d8 Compare August 28, 2026 16:21
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