<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>CompanyCompany · Latest</title>
  <subtitle>Products, experiments, and the work behind them.</subtitle>
  <link href="https://companycompany.company/" />
  <link href="https://companycompany.company/atom.xml" rel="self" type="application/atom+xml" />
  <id>https://companycompany.company/</id>
  <updated>2026-09-07T00:00:00.000Z</updated>
  <author>
    <name>CompanyCompany</name>
    <uri>https://companycompany.company/</uri>
  </author>
  <generator>publishing-system prerender</generator>
  <entry>
    <title>Kinship labels for a 426 person tree</title>
    <link href="https://companycompany.company/updates/kinship-labels" />
    <id>https://companycompany.company/updates/kinship-labels</id>
    <published>2026-09-07T00:00:00.000Z</published>
    <updated>2026-09-07T00:00:00.000Z</updated>
    <summary>A family tree that explains how you are related, in English, Persian and French. Caching two graph walks brought a labelled canvas from 645ms to 37ms.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;The archive is a self-hosted family tree running as a single Cloudflare Worker with one D1 database and one R2 bucket. Version 214 already put a kinship word on every card, outline row and person panel, but it rendered only for a viewer who had claimed a person in the tree, and no member had claimed one, so in practice a connector line still said two people were joined and not how. Versions 215 through 222 made the seat resolvable and gave the words their range: your father, your paternal uncle, your second cousin. The seat resolves in three tiers. A signed-in member who has claimed a person in the tree wins; a password visitor can seat themselves in browser-local state, which matters because most of this family has no account; with no account and no claim, whoever the reader last selected becomes the seat, which is what makes the feature visible to a stranger on the public demo. lib/relationship-path.ts computes the relation and now also reports which parent a blood line climbs through, so an uncle carries his side. lib/kinship-words.ts renders the result in English, Persian and French, where the distinction is not cosmetic: a paternal uncle and a maternal uncle are different words.&lt;/p&gt;
&lt;p&gt;Version 221 named the in-laws instead of falling back to a generic phrase. Five relations are named directly (son and daughter-in-law, father and mother-in-law, brother and sister-in-law, step-parent, step-child), and everyone else is described through the marriage, as in your paternal uncle's wife or your husband's nephew. Persian picks the word by which family was married into, so a man's mother-in-law and a woman's mother-in-law are two different terms. On the live 426-person tree that gave 130 people a real word for their connection and left five on the generic phrase. It also cost a great deal: naming an in-law asks a second relationship question through each marriage bridge, and labelling one canvas went from 19ms to 645ms. The fix was to cache both graph walks per source person inside relationshipIndex, the ancestor walk and the breadth-first predecessor sweep. One sweep from a seat reaches everybody, so labelling hundreds of cards is one sweep rather than hundreds. The whole tree came back to 37ms with the in-laws named. The code comment records the reason the sweep does not stop at its target: the next question starts from the same person anyway.&lt;/p&gt;
&lt;p&gt;Two related pieces landed on the same graph. Ninety-three percent of this archive's people have no birth date, so every dated view simply omitted them. lib/estimated-dates.ts places the undated from their relatives' recorded years using a 28-year generation, with siblings and spouses treated as close, propagated through the graph with a widening spread. Estimates are computed on read and never written to a record, and every consumer marks them, so the timeline went from 48 entries to 446 and each new one reads c. 1930 with the relatives it rests on. lib/who-can-answer.ts then ranks what the archive is missing, 398 birth dates and 408 birth places, by the number of steps to the nearest living relative, on the reasoning that nobody alive can say where a man born in 1780 was born. 421 of 422 incomplete records turned out to have a living relative within reach. The weekly digest became one letter per member carrying three questions about records near that member's own seat, widening the search radius until it finds three worth asking.&lt;/p&gt;</content>
    <category term="genealogy" />
    <category term="graph-algorithms" />
    <category term="performance" />
    <category term="i18n" />
    <category term="cloudflare-workers" />
    <category term="code-review" />
  </entry>
  <entry>
    <title>A newsletter, and the review that took it apart</title>
    <link href="https://companycompany.company/updates/newsletter-review" />
    <id>https://companycompany.company/updates/newsletter-review</id>
    <published>2026-09-07T00:00:00.000Z</published>
    <updated>2026-09-07T00:00:00.000Z</updated>
    <summary>The first version shipped in one push, returned 500 on every route for two unrelated reasons, and then turned out to be an email bomb aimed at anyone.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;An email newsletter went live on the blog and then got attacked on purpose. Commit 7f04dde added the whole first version in one push, 1,054 insertions across ten files: api/newsletter.ts at 199 lines, src/lib/newsletterCore.ts at 216, scripts/newsletter-send.ts at 189, src/components/Subscribe.tsx at 104, and the remainder in its test, the doc, a stylesheet, the blog page and config. The subscriber list lives in a GitHub gist reached with the GITHUB_TOKEN the CMS already uses, so the store needed no new service. Mail did not survive first contact. The first version sent through Resend, which nobody had an account for, and 2b1f3d5 replaced it with SMTP to MXroute, the owner's existing mail host, whose account, sending reputation and management credentials were already in place. Provisioned on 2026-09-07: the domain added to that account after proving ownership with a &lt;em&gt;da-verify TXT record at Porkbun, and a newsletter@retroportingtoolkit.com mailbox capped at 500 messages a day. DNS at Porkbun got DKIM at x.&lt;/em&gt;domainkey, SPF extended additively from include:_spf.porkbun.com to also carry include:mxroute.com, and DMARC at p=none. MX was deliberately left pointing at Porkbun forwarding, because this domain sends mail and does not receive it, and repointing it would have broken existing forwarding. The cost of that choice is recorded: bounces and DMARC rua reports go to forwarding nobody is watching.&lt;/p&gt;
&lt;p&gt;The endpoint returned 500 on every route for two unrelated reasons, each costing a full deploy-and-read-the-logs cycle. First, api/newsletter.ts imported "../src/lib/newsletterCore" with no extension. The module was bundled into the lambda, vercel build writes src/lib/newsletterCore.js right beside the function, but the package is type: module, so Node's ESM resolver will not guess an extension the way TypeScript's bundler resolution does; 9953262 added the .js. Second, the function used export default, which Vercel routes through the legacy Node launcher where req.url is a bare path and new URL throws ERR_INVALID_URL. Named GET and POST exports get the Web-standard Request, whose url is absolute, which is why api/cms.ts had worked all along; 2b1f3d5 carried that fix alongside the transport change. Separately, Vercel environment variables turned out to be write-only: vercel env pull returns project variables empty, so NEWSLETTER_SECRET had to be rotated because the original was piped into Vercel and nowhere else, which meant the send script could never have signed an unsubscribe link.&lt;/p&gt;
&lt;p&gt;Once it was live it was reviewed as a stranger would attack it, and three defects were real. Every POST to /subscribe mailed whatever address it named, uncapped, which is an email bomb aimed at anyone, sent from this domain, draining a 500-a-day mailbox and the GITHUB_TOKEN rate limit that /admin shares. Commit 2327deb added subscribeDecision as a pure function in newsletterCore.ts: CONFIRM_COOLDOWN_MS of 15 minutes per address, never a second mail to an address already confirmed, and MAX_PENDING of 500 unconfirmed records, a ceiling that counts only records an attacker can create. The escalation that made it permanent was that readList ignored GitHub's truncated flag, so about a megabyte of junk addresses would have left every route unable to parse the list until a human edited the gist by hand; 3dca3ad made that flag throw. Confirm and unsubscribe also changed state on GET, so a victim's own mail scanner could complete their double opt-in or silently unsubscribe them. Both now render a button and act only on POST, which is what RFC 8058 One-Click wants anyway. Confirm and unsubscribe lifetimes were split, CONFIRM_MAX_AGE_MS at 14 days against UNSUB_MAX_AGE_MS at 10 years, because an unsubscribe link has to outlive the archive it sits in.&lt;/p&gt;</content>
    <category term="newsletter" />
    <category term="email" />
    <category term="security" />
    <category term="cryptography" />
    <category term="vercel" />
    <category term="smtp" />
  </entry>
  <entry>
    <title>Two blank pages that every existing gate passed</title>
    <link href="https://companycompany.company/updates/two-blank-pages" />
    <id>https://companycompany.company/updates/two-blank-pages</id>
    <published>2026-09-07T00:00:00.000Z</published>
    <updated>2026-09-07T00:00:00.000Z</updated>
    <summary>Both sites rendered nothing under conditions no test covered, and both builds exited 0. What was missing was a gate that loads the built artifact.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;Both sites were rendering nothing under conditions no test covered, and both builds exited 0. On the pseudonymous site, the per-site build transform in vite.config.ts emitted a stub for every record that site does not publish, and the stub failed content validation. parseAll() in src/lib/publishingContent.ts throws at module init, so React never mounted on any route. Every page served its prerendered static shell with no stylesheet, no navigation and no app. A second cause sat inside the same throw: a record renamed for the pseudonymous site through shokuninSlug kept its updates pointing at the old slug, which failed the relation check.&lt;/p&gt;
&lt;p&gt;Why nothing caught it is the part worth keeping. The type checker, 273 unit tests, the prerender parity tests and the output isolation gate all passed. scripts/vite-prerender.mjs reads the content files directly rather than through the bundle, so the static shells were correct while the client bundle was broken, and nothing in the repo ever loaded the built artifact. The fix makes the withheld stub valid while carrying no professional title, description or body, and keeps it marked draft so it stays out of every index, feed and sitemap.&lt;/p&gt;
&lt;p&gt;The second failure was unrelated in cause and identical in symptom. src/three/SiteCanvas.tsx mounts a full-viewport WebGL canvas on every non-immersive route. A failed context threw past React and unmounted the whole app, so four top-level routes rendered blank. Detail routes survived by accident: a modal covers the canvas, so it never mounted there. Hardware acceleration turned off, an old integrated GPU, a locked-down corporate build and the browser's own GPU blocklist all produce this. The layer is decorative, and it now fails to null.&lt;/p&gt;
&lt;p&gt;scripts/check-renders.mjs is the gate for this class. It serves the built output over HTTP, drives headless Chrome over the DevTools protocol, loads every route of both sites, and asserts the app actually mounted rather than that something rendered. The floor is 60 characters of text, set deliberately below the shortest real page, an update stub at 99 characters. Routes are discovered by walking the built output, so a new route is covered the moment it is prerendered. 192 routes pass. Reverting the stub fix drops the pseudonymous site to 0 of 13. A --no-gpu flag covers the WebGL case and replaced an older single-purpose script.&lt;/p&gt;</content>
    <category term="testing" />
    <category term="prerendering" />
    <category term="failure-analysis" />
    <category term="build-tooling" />
  </entry>
  <entry>
    <title>LSDj export and import, verified in mGBA</title>
    <link href="https://companycompany.company/updates/lsdj-export" />
    <id>https://companycompany.company/updates/lsdj-export</id>
    <published>2026-09-04T00:00:00.000Z</published>
    <updated>2026-09-04T00:00:00.000Z</updated>
    <summary>Take a generated song into a musician’s existing tools and keep composing. The export is checked by running LSDj in mGBA, not by trusting our own decoder.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;Chiptunes can now write files that Little Sound DJ opens, and read them back. &lt;code&gt;src/lsdj.js&lt;/code&gt; emits a &lt;code&gt;.lsdsng&lt;/code&gt;, the single song LSDj musicians pass around, and a whole &lt;code&gt;.sav&lt;/code&gt; cartridge holding up to 32 songs, which takes about 40 ms for ten. &lt;code&gt;api.fromLsdsng()&lt;/code&gt; walks either one back the way LSDj does, sequence to chains to phrases to rows, into an ordinary document; it tells the two apart by size, since the name bytes are arbitrary text and cannot be used as a signature. The surfaces are &lt;code&gt;api.toLsdsng()&lt;/code&gt;, &lt;code&gt;api.toLsdjSav()&lt;/code&gt;, &lt;code&gt;npx chiptunes lsdsng&lt;/code&gt; and &lt;code&gt;npx chiptunes lsdjcart&lt;/code&gt; in &lt;code&gt;bin/chiptunes.js&lt;/code&gt;, the MCP tools &lt;code&gt;export_lsdsng&lt;/code&gt; and &lt;code&gt;export_lsdj_cart&lt;/code&gt; in &lt;code&gt;mcp/server.js&lt;/code&gt;, and a Download LSDj button in the tracker. It is faithful rather than converted: a bar is a phrase of 16 steps, channels map one to one onto PU1/PU2/WAV/NOI, the groove crosses intact, and an arpeggio exports as a &lt;code&gt;C&lt;/code&gt; command instead of three hundred rows of spelled out notes, so what lands is a phrase somebody can read.&lt;/p&gt;
&lt;p&gt;The verification was built to avoid one specific failure. &lt;code&gt;scripts/verify-lsdj.js&lt;/code&gt; reads the output back with liblsdj itself, not with our own decompressor, because a self round trip is code and test agreeing with each other, which this repo had already done once with WebMCP. The gate header carries the two commands that build the reader, a &lt;code&gt;git clone&lt;/code&gt; of liblsdj and one &lt;code&gt;clang&lt;/code&gt; line over &lt;code&gt;tools/lsdjcheck.c&lt;/code&gt;, and without that library present it runs the structural checks and says loudly that the strong one was skipped. The stronger proof came from &lt;code&gt;tools/lsdjplay.c&lt;/code&gt;, which boots the owner's real LSDj ROM headlessly in mGBA, loads one of our saves, presses START and reads the decoded channel state. It reports two sample sets on purpose: HZ samples every frame and catches every note including idle channels, so it is the set to check for missing notes, and TRIG samples only while a channel reports playing, so it is the set to check for wrong ones. Gating on the playing flag alone found 9 of 12 notes, a fault in the observer rather than in the music, which is why both sets are kept. Two gotchas live in the harness comments: mGBA faults during reset with SIGBUS and no message if it has no video buffer or no &lt;code&gt;mCoreInitConfig&lt;/code&gt;, and NR13/NR23/NR33 are write only, so reading them back through &lt;code&gt;busRead8&lt;/code&gt; returns nothing and the decoded &lt;code&gt;gb-&amp;gt;audio.chN&lt;/code&gt; state is the honest place to look.&lt;/p&gt;
&lt;p&gt;Two real bugs fell out of it. The block jump in the decompressor was off by one: blocks are numbered from 1 and block N lives at &lt;code&gt;(N-1)*512&lt;/code&gt;, but the reader went to &lt;code&gt;N*512&lt;/code&gt; and silently lost 512 bytes at every boundary. Nothing caught it because the only image ever round tripped through the codec was the empty song, which compresses to under one block and never jumps at all, while the export measured against it that day was 2569 bytes across five blocks. Exports were fine, since the writer emits correct jumps and LSDj has its own decompressor, but every file we produced was unreadable by us, which is the half that parity needs. The second was pitch. &lt;code&gt;NOTE_ZERO_MIDI&lt;/code&gt; shipped as one constant, 36 for every channel, and a straight mapping off it clamped 111 notes of a busy boss cue; a clamped note is a wrong note that looks deliberate, so the export shifted whole octaves instead and seven songs in 32 had to move. The hardware settled it without a Game Boy in the room: the DMG computes pulse frequency as 131072/(2048-x) and wave at half that, so pulse spans MIDI 36 to 108 and wave reaches a full octave lower, 24 to 96. &lt;code&gt;NOTE_BASE&lt;/code&gt; became &lt;code&gt;[36, 36, 24, 36]&lt;/code&gt; at line 127 of &lt;code&gt;src/lsdj.js&lt;/code&gt;, the workaround disappeared, and no song needs a shift any more. &lt;code&gt;verify-lsdj&lt;/code&gt; now asserts the table against &lt;code&gt;gb-hardware.inRange()&lt;/code&gt; directly and asserts the shift count is zero.&lt;/p&gt;</content>
    <category term="lsdj" />
    <category term="game-boy" />
    <category term="chiptune" />
    <category term="file-format" />
    <category term="emulation" />
    <category term="verification" />
  </entry>
  <entry>
    <title>One missing texture blacked out the whole gallery</title>
    <link href="https://companycompany.company/updates/xr-texture-failure" />
    <id>https://companycompany.company/updates/xr-texture-failure</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>A card image that was never generated threw through Suspense and unmounted the entire WebXR scene. The fix is a per-tile boundary, a generator case, and a test that walks every record.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;The split-view pane on /projects/spatial-gallery rendered black in production. The pane embeds /xr, and /xr itself was broken: a new writing record, data/writing/13_ai-native-product-development, had no generated card art at public/lab-media/writing/ai-native-product-development.webp. drei's useTexture throws through Suspense when the fetch 404s, nothing caught it, and the uncaught error unmounted the entire Canvas. One missing image file, 32,846 bytes once it was finally generated, took down every project, talk and article in the gallery.&lt;/p&gt;
&lt;p&gt;The fix in 5c86f6d is three parts. TileMaterialBoundary, a class component in src/lab/MediaTile.tsx using getDerivedStateFromError, now wraps ImageMaterial so a failed texture costs one tile instead of the scene. Its fallback is a meshBasicMaterial in the card's own accent color, so the tile still reads as a card with its title label rather than disappearing. componentDidCatch logs a warning instead of swallowing it.&lt;/p&gt;
&lt;p&gt;The generator was also wrong. scripts/gen-lab-media.sh handled covers written as ./relative paths and as http URLs, but not root-relative public paths, so a record declaring cover: "/og/..." silently produced nothing. A /* case mapping the cover to public$cover was added.&lt;/p&gt;
&lt;p&gt;The part that stops this recurring is a test. scripts/content-media.test.mjs now walks data/talks and data/writing, strips the NN_ prefix from each directory, and asserts that every slug has public/lab-media/&amp;lt;kind&amp;gt;/&amp;lt;slug&amp;gt;.webp, or a preview clip pair when src/lab/talkClips.ts lists that slug. The suite is 255 tests and passes.&lt;/p&gt;</content>
    <category term="WebXR" />
    <category term="three.js" />
    <category term="error handling" />
    <category term="regression test" />
    <category term="production defect" />
  </entry>
  <entry>
    <title>A 2.2 second split between the direct and relay paths</title>
    <link href="https://companycompany.company/updates/direct-relay-split" />
    <id>https://companycompany.company/updates/direct-relay-split</id>
    <published>2026-08-11T00:00:00.000Z</published>
    <updated>2026-08-11T00:00:00.000Z</updated>
    <summary>A room should hear the same moment together. Real-player measurements found a 2.2-second direct/relay split and exposed a misleading test proxy. The timing limit remains open.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;The plan was to pay off a field-verification debt on the relay path with a real muted AVPlayer. The measurement found that the direct path was the broken one. Two two-minute soaks ran against a live room, 24 samples each, through scripts/soak-playback.swift, which computes wall clock minus the PROGRAM-DATE-TIME under the playhead, so the number is end-to-end delay and not distance from a playlist edge. The relay path sat at 3.33 seconds and passed. The direct path sat at 1.17 seconds and failed, against a declared target of 3.000 seconds. Both logs are committed under docs/receipts because the contract asks for the log, not a claim about where a player sat, and .log was in .gitignore until then.&lt;/p&gt;
&lt;p&gt;The direct path serves the exact manifest form the contract records as correct: EXT-X-START:TIME-OFFSET=-3.000,PRECISE=YES on the multivariant playlist, 500 ms segments, 150 ms parts, a 0.9 second PART-HOLD-BACK floor. 1.17 seconds is roughly hold-back plus the packaging floor, which is where a player lands when the attachment pin does nothing at all. It reproduced under forced relay and again in clean auto/direct, so it was not the mode override. The relay's 3.33 seconds is near the target by accident: nothing authors an attachment point on that side, since contribute.cycle reads the multivariant only to learn the variant name and RewritePlaylist adds the pin only to a playlist carrying EXT-X-STREAM-INF. Relay is direct plus the relay hop. A room holding both kinds of guest is about 2.2 seconds out of sync with itself, which is the single thing internal/schedule exists to prevent.&lt;/p&gt;
&lt;p&gt;The instrument meant to validate a candidate fix turned out not to measure the thing. Three more soaks through scripts/bench-playlist-proxy.py on the same live room returned 3.16s with no pin at all, 3.12s with production's master-only pin, and 3.20s with the pin on both playlists. All three passed, while the same harness on the real guest path returned 1.17s and failed. It was not manifest staleness: newest PDT was 0.88s old through the proxy and 0.87s direct. A player falls behind a synchronous Python proxy that cannot keep up with LL-HLS part fetching and lands about 3s back regardless of input. The proxy also carried no MediaMTX session cookie, so every media playlist came back 401 and its media-tier rewrite had never executed once. That casts doubt backward: the earlier record of the D=3 form being re-proven at 3.11s sits squarely inside the band this proxy returns for any manifest, including one with no pin.&lt;/p&gt;
&lt;p&gt;The decision was to measure and document, deliberately not to fix. Any change to hold-back, EXT-X-START, room delay or segment shape is gated on a soak that passes before a build moves plus a supervised go-live test, and the three seconds is not a sync target: it was bought as buffer after a set at Shack 15 produced constant random cutoffs. Lowering the declared target to the number direct sits at today would bring the dropouts back. That makes 1.17s worse news than it first looked, since the missing cushion is on the path nearly every guest uses.&lt;/p&gt;</content>
    <category term="latency" />
    <category term="hls" />
    <category term="measurement" />
    <category term="ios" />
    <category term="instrumentation" />
  </entry>
  <entry>
    <title>Spatial Gallery</title>
    <link href="https://companycompany.company/updates/spatial-gallery" />
    <id>https://companycompany.company/updates/spatial-gallery</id>
    <published>2026-06-25T00:00:00.000Z</published>
    <updated>2026-06-25T00:00:00.000Z</updated>
    <summary>A dual-mode WebXR gallery with gaze-and-pinch interaction on Apple Vision Pro and the same real-time 3D scene on standard browsers.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;&lt;img src="https://companycompany.company/previews/spatial-gallery.webp" alt="Spatial Gallery" /&gt;&lt;/p&gt;
&lt;p&gt;A spatial edition of the site: a coved-rotunda gallery you move through, with projects, talks, and writing hung as art and each talk playing as full video while you stand in front of it.&lt;/p&gt;
&lt;p&gt;The same scene runs two ways. On Apple Vision Pro it enters a true immersive WebXR session driven by gaze and pinch, room-scale, with the gallery taking over your view. Everywhere else it is a flat, mouse and touch navigable 3D scene in the page.&lt;/p&gt;
&lt;p&gt;It is also a study in shipping real-time 3D with AI as the build partner. The work included locomotion designed to avoid discomfort, a frame budget targeting 90fps on the M2, and self-hosted talk video that can play on WebXR surfaces.&lt;/p&gt;</content>
    <category term="WebXR" />
    <category term="three.js" />
    <category term="Apple Vision Pro" />
    <category term="AI-built" />
  </entry>
  <entry>
    <title>Retro Radio</title>
    <link href="https://companycompany.company/updates/retro-radio" />
    <id>https://companycompany.company/updates/retro-radio</id>
    <published>2026-06-20T00:00:00.000Z</published>
    <updated>2026-06-20T00:00:00.000Z</updated>
    <summary>A playable retro-styled web radio that became the starting point for chiptunes.app.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;&lt;img src="https://companycompany.company/previews/retro-radio.webp" alt="Retro Radio" /&gt;&lt;/p&gt;
&lt;p&gt;Retro Radio began as a small playable web-audio experiment. It is preserved here as the historical step that led to chiptunes.app rather than being rewritten as though the product existed fully formed.&lt;/p&gt;</content>
    <category term="Web Audio" />
    <category term="Music" />
    <category term="AI-built" />
  </entry>
  <entry>
    <title>Building the publishing system</title>
    <link href="https://companycompany.company/updates/building-the-publishing-system" />
    <id>https://companycompany.company/updates/building-the-publishing-system</id>
    <published>2026-06-18T00:00:00.000Z</published>
    <updated>2026-06-18T00:00:00.000Z</updated>
    <summary>A typed content graph, a deterministic two-site compiler, fully prerendered routes, a compiled client-side search index, and a spatial WebXR edition.</summary>
    <author><name>CompanyCompany</name></author>
    <content type="html">&lt;p&gt;This site is a production publishing system and an ongoing product-development practice. AI participates across research, information architecture, content modeling, interface design, engineering, evidence, testing, deployment, and editorial operations, while one human remains responsible for the decisions, integration, verification, and result.&lt;/p&gt;
&lt;p&gt;The system includes citations that renumber per view, a compiled client-side search index spanning full article text, a real-time WebGL home, and a WebXR gallery for Apple Vision Pro.&lt;/p&gt;
&lt;p&gt;Every important route is prerendered to static HTML without a generic SPA fallback. Media passes through an optimization pipeline that emits modern formats, placeholders, responsive images, and fallbacks.&lt;/p&gt;</content>
    <category term="React" />
    <category term="WebGL" />
    <category term="WebXR" />
    <category term="AI-built" />
  </entry>
</feed>
