@cardstack/boxel-cli 0.9.0-unstable.5 → 0.9.0-unstable.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bundled-test-harness/assets/{ai-module-creation-test-BNiZ9Als.js → ai-module-creation-test-CYdqfeyE.js} +1 -1
  2. package/bundled-test-harness/assets/{app-CP2kZsut.js → app-DEPzUD4Y.js} +1 -1
  3. package/bundled-test-harness/assets/{binary-upload-dedupe-test-c4W0iix2.js → binary-upload-dedupe-test-BIf-AWak.js} +1 -1
  4. package/bundled-test-harness/assets/{can-invoke-test-DyozfCFg.js → can-invoke-test-BjgjfYqo.js} +1 -1
  5. package/bundled-test-harness/assets/{card-chooser-paging-test-BKdBwPQ3.js → card-chooser-paging-test-C2v55yZ3.js} +1 -1
  6. package/bundled-test-harness/assets/{card-chooser-test-DAPWVfJL.js → card-chooser-test-OqnnAsAY.js} +1 -1
  7. package/bundled-test-harness/assets/{card-def-field-def-relationships-test-Dz1qg9ZW.js → card-def-field-def-relationships-test-TNzXYsVI.js} +1 -1
  8. package/bundled-test-harness/assets/{card-delete-test-BmI-DLtn.js → card-delete-test-BVnE3BU6.js} +1 -1
  9. package/bundled-test-harness/assets/{choreo-feature-reel-test-S0XwN4xA.js → choreo-feature-reel-test-DrvZlnG0.js} +1 -1
  10. package/bundled-test-harness/assets/{choreo-gallery-B0nnktwP.js → choreo-gallery-D8__QZCe.js} +11 -11
  11. package/bundled-test-harness/assets/{choreo-gallery-build-order-test-C6q9CDNY.js → choreo-gallery-build-order-test-7TpxKQd2.js} +1 -1
  12. package/bundled-test-harness/assets/{choreo-gallery-compositor-test-C-GwjEUw.js → choreo-gallery-compositor-test-BgMWB-xz.js} +1 -1
  13. package/bundled-test-harness/assets/choreo-gallery-cover-test-uR-CNV8g.js +205 -0
  14. package/bundled-test-harness/assets/{choreo-gallery-crossing-stage-test-B7jRQTAh.js → choreo-gallery-crossing-stage-test-CmGH0E5q.js} +1 -1
  15. package/bundled-test-harness/assets/{choreo-gallery-demos-alive-test-BLqE-zk1.js → choreo-gallery-demos-alive-test-DZ1sPqe8.js} +8 -2
  16. package/bundled-test-harness/assets/{choreo-gallery-drift-test-DOXLQNB9.js → choreo-gallery-drift-test-q7KosXIw.js} +1 -1
  17. package/bundled-test-harness/assets/{choreo-gallery-escort-test-lfBfmp-4.js → choreo-gallery-escort-test-CH_qyw6w.js} +1 -1
  18. package/bundled-test-harness/assets/{choreo-gallery-films-test-M761cc8Q.js → choreo-gallery-films-test-CYmek5qU.js} +1 -1
  19. package/bundled-test-harness/assets/{choreo-gallery-filter-test-BrUWMDaJ.js → choreo-gallery-filter-test-Curg5Fg7.js} +1 -1
  20. package/bundled-test-harness/assets/{choreo-gallery-fold-test-CTtdGSAI.js → choreo-gallery-fold-test-BDTkaH6H.js} +1 -1
  21. package/bundled-test-harness/assets/{choreo-gallery-guides-test-PHmvqxfk.js → choreo-gallery-guides-test-B1vY7Fkx.js} +2 -2
  22. package/bundled-test-harness/assets/{choreo-gallery-hang-test-DP5lQG0y.js → choreo-gallery-hang-test-BJJghTnK.js} +1 -1
  23. package/bundled-test-harness/assets/{choreo-gallery-highlight-test-CLhvRo6X.js → choreo-gallery-highlight-test-DGT0jmDf.js} +2 -2
  24. package/bundled-test-harness/assets/{choreo-gallery-inline-edit-test-jOddqOxH.js → choreo-gallery-inline-edit-test-DwF8ZeCB.js} +1 -1
  25. package/bundled-test-harness/assets/{choreo-gallery-interruption-test-hdQSH8Mi.js → choreo-gallery-interruption-test-DvfkLdWd.js} +1 -1
  26. package/bundled-test-harness/assets/{choreo-gallery-lone-stack-test-AHGiG8yJ.js → choreo-gallery-lone-stack-test-CDwE2xKJ.js} +1 -1
  27. package/bundled-test-harness/assets/{choreo-gallery-navigation-test-BlGmMyFM.js → choreo-gallery-navigation-test-DG6IdX6k.js} +1 -1
  28. package/bundled-test-harness/assets/{choreo-gallery-playhead-transport-test-8aNOk09i.js → choreo-gallery-playhead-transport-test-CITO-DAn.js} +1 -1
  29. package/bundled-test-harness/assets/{choreo-gallery-poplayout-subtree-test-BCJLJdyC.js → choreo-gallery-poplayout-subtree-test-Cs4S9Ky9.js} +1 -1
  30. package/bundled-test-harness/assets/{choreo-gallery-presence-modes-test-CbhdndwS.js → choreo-gallery-presence-modes-test-p8mppXAD.js} +1 -1
  31. package/bundled-test-harness/assets/{choreo-gallery-presentation-test-DDUagg6a.js → choreo-gallery-presentation-test-CT0lZepE.js} +36 -1
  32. package/bundled-test-harness/assets/{choreo-gallery-sheet-standalone-test-BNyhCtW2.js → choreo-gallery-sheet-standalone-test-D-MLCQ6u.js} +1 -1
  33. package/bundled-test-harness/assets/{choreo-gallery-sheet-test-NEe_oYnA.js → choreo-gallery-sheet-test-unHvAt2e.js} +1 -1
  34. package/bundled-test-harness/assets/{choreo-gallery-site-test-hT5mISBG.js → choreo-gallery-site-test-BRHuPInI.js} +1 -1
  35. package/bundled-test-harness/assets/{choreo-gallery-stage-Bmz9m8sP.js → choreo-gallery-stage-DVGS6XI-.js} +1 -1
  36. package/bundled-test-harness/assets/choreo-gallery-stage-keyboard-test-BYpAwZPD.js +102 -0
  37. package/bundled-test-harness/assets/{choreo-gallery-stages-test-DZjVu0Zq.js → choreo-gallery-stages-test-DPFv-GKb.js} +2 -2
  38. package/bundled-test-harness/assets/{choreo-gallery-subdivision-test-DBjnIZ8l.js → choreo-gallery-subdivision-test-C4rwq-DA.js} +1 -1
  39. package/bundled-test-harness/assets/{choreo-gallery-table-plan-test-DTSu2DjF.js → choreo-gallery-table-plan-test-CAQdsCBB.js} +1 -1
  40. package/bundled-test-harness/assets/{choreo-gallery-theater-test-BsLF8HcI.js → choreo-gallery-theater-test-BxvAn-i8.js} +2 -2
  41. package/bundled-test-harness/assets/{choreo-gallery-tuning-test-B1JW701Z.js → choreo-gallery-tuning-test-CvAiiRMn.js} +1 -1
  42. package/bundled-test-harness/assets/{choreo-gallery-widget-room-test-DcEIPub1.js → choreo-gallery-widget-room-test-DLkgh8Ws.js} +2 -2
  43. package/bundled-test-harness/assets/{choreo-gallery-wires-test-6VOc5SF8.js → choreo-gallery-wires-test-DMdHX2zj.js} +1 -1
  44. package/bundled-test-harness/assets/{codeblocks-test-DGtwtURg.js → codeblocks-test-DDMirwuJ.js} +1 -1
  45. package/bundled-test-harness/assets/{compaction-test-CTNozvI6.js → compaction-test-BKQ_668T.js} +1 -1
  46. package/bundled-test-harness/assets/{container-Az9Nq0LL.js → container-BrZklF85.js} +5 -2
  47. package/bundled-test-harness/assets/{create-listing-modal-test-cdE1T_YD.js → create-listing-modal-test-Bsgixn9Z.js} +1 -1
  48. package/bundled-test-harness/assets/{debug-message-test-BfS52C_5.js → debug-message-test-8e9w8xNX.js} +1 -1
  49. package/bundled-test-harness/assets/{fallback-banner-live-delete-test-C6Rl_3oe.js → fallback-banner-live-delete-test-CNsd-0FB.js} +1 -1
  50. package/bundled-test-harness/assets/{fallback-banner-test-CsSGrseR.js → fallback-banner-test-DlT-8Lpv.js} +1 -1
  51. package/bundled-test-harness/assets/{fallback-models-test-D7FBFpyT.js → fallback-models-test-CwG7Ydui.js} +1 -1
  52. package/bundled-test-harness/assets/{file-attachment-test-DtPZTxy4.js → file-attachment-test-Cvs-ulUS.js} +1 -1
  53. package/bundled-test-harness/assets/{general-test-CMH4p9LD.js → general-test-BDjfjhSB.js} +1 -1
  54. package/bundled-test-harness/assets/{main-DQBeCoaR.js → main-BeQF5cZd.js} +1 -1
  55. package/bundled-test-harness/assets/{model-cost-tier-test-DR2G3zVF.js → model-cost-tier-test-FFf8YNd5.js} +1 -1
  56. package/bundled-test-harness/assets/{operator-mode-card-chooser-test-Dtd5macc.js → operator-mode-card-chooser-test-DKo-UoRQ.js} +1 -1
  57. package/bundled-test-harness/assets/{operator-mode-links-test-BW7fJlL7.js → operator-mode-links-test-Dl5g5COc.js} +1 -1
  58. package/bundled-test-harness/assets/{operator-mode-test-OwSg0jux.js → operator-mode-test-Bo8diNLE.js} +1 -1
  59. package/bundled-test-harness/assets/{operator-mode-ui-test-gV3LQASK.js → operator-mode-ui-test-CSc-CYTU.js} +1 -1
  60. package/bundled-test-harness/assets/{overlay-menu-items-test-DhsN6QTO.js → overlay-menu-items-test-Bs1zoaym.js} +1 -1
  61. package/bundled-test-harness/assets/{past-sessions-test-DD6A7KuB.js → past-sessions-test-BGg7ZyON.js} +1 -1
  62. package/bundled-test-harness/assets/{permission-checking-realm-test-bQz4K37U.js → permission-checking-realm-test-Be6jGmyM.js} +1 -1
  63. package/bundled-test-harness/assets/{realm-config-policy-standing-test-Cs9XpWCX.js → realm-config-policy-standing-test-nM6CGX-Q.js} +1 -1
  64. package/bundled-test-harness/assets/{realm-config-routing-rule-edit-test-_D_xuw3p.js → realm-config-routing-rule-edit-test-TIuE2nug.js} +1 -1
  65. package/bundled-test-harness/assets/{realm-config-settings-test-DNqVtlJT.js → realm-config-settings-test-CWqwPMkw.js} +1 -1
  66. package/bundled-test-harness/assets/{reasoning-test-B74P8G_y.js → reasoning-test-CfRBo1ut.js} +1 -1
  67. package/bundled-test-harness/assets/{scrolling-test-EMUFGWKu.js → scrolling-test-DTJ7HVdE.js} +1 -1
  68. package/bundled-test-harness/assets/{sending-test-BIADsBdp.js → sending-test-D6iQh346.js} +1 -1
  69. package/bundled-test-harness/assets/{skills-test-AcW3L6RD.js → skills-test-Ca1EpjMI.js} +1 -1
  70. package/bundled-test-harness/assets/{store-test-CgPJ9v5Z.js → store-test-BS1Ue4Rt.js} +1 -1
  71. package/bundled-test-harness/assets/{tests-QZJXdl8x.js → tests-CPowoOp0.js} +471 -469
  72. package/bundled-test-harness/assets/{text-input-validator-test-DybvZTP3.js → text-input-validator-test-XpZxMbh0.js} +1 -1
  73. package/bundled-test-harness/assets/{to-device-streaming-test-BbgQFsQk.js → to-device-streaming-test-CKu1Ud8R.js} +1 -1
  74. package/bundled-test-harness/assets/{token-usage-test-BkQp1TTc.js → token-usage-test-Cszf7EU8.js} +1 -1
  75. package/bundled-test-harness/assets/{tools-test-DEfT44PZ.js → tools-test-DdlkGAFH.js} +1 -1
  76. package/bundled-test-harness/index.html +4 -4
  77. package/bundled-test-harness/tests/index.html +4 -4
  78. package/bundled-types/host-app/components/host-mode/content.gts +9 -0
  79. package/dist/api.js +166 -165
  80. package/dist/index.js +271 -270
  81. package/package.json +1 -1
  82. package/src/commands/realm-server/data-keys.ts +7 -14
  83. package/src/commands/realm-server/index.ts +2 -0
  84. package/src/commands/realm-server/keys.ts +496 -0
  85. package/src/lib/prompt.ts +45 -12
  86. package/src/lib/seed-auth.ts +63 -3
@@ -12,7 +12,7 @@ var quick_script_default = "[\n {\n \"title\": \"Same elements. New possibil
12
12
  var voices_default = "{\n \"narrationReady\": true,\n \"narrator\": \"George\",\n \"provider\": \"ElevenLabs\",\n \"model\": \"eleven_multilingual_v2\",\n \"stops\": [\n {\n \"id\": \"gestures\",\n \"seconds\": 8.176327\n },\n {\n \"id\": \"enter\",\n \"seconds\": 4.728163\n },\n {\n \"id\": \"presence\",\n \"seconds\": 5.146122\n },\n {\n \"id\": \"keyframes\",\n \"seconds\": 5.276735\n },\n {\n \"id\": \"path\",\n \"seconds\": 4.675918\n },\n {\n \"id\": \"pointer\",\n \"seconds\": 5.877551\n },\n {\n \"id\": \"stagger\",\n \"seconds\": 3.892245\n },\n {\n \"id\": \"trail\",\n \"seconds\": 6.817959\n },\n {\n \"id\": \"tabs\",\n \"seconds\": 7.000816\n },\n {\n \"id\": \"layout\",\n \"seconds\": 5.250612\n },\n {\n \"id\": \"lists\",\n \"seconds\": 6.217143\n },\n {\n \"id\": \"reorder\",\n \"seconds\": 5.198367\n },\n {\n \"id\": \"grid\",\n \"seconds\": 5.799184\n },\n {\n \"id\": \"inline-edit\",\n \"seconds\": 6.870204\n },\n {\n \"id\": \"lightbox\",\n \"seconds\": 7.131429\n },\n {\n \"id\": \"split\",\n \"seconds\": 7.549388\n },\n {\n \"id\": \"inbox\",\n \"seconds\": 8.907755\n },\n {\n \"id\": \"far\",\n \"seconds\": 6.635102\n },\n {\n \"id\": \"crossing\",\n \"seconds\": 5.694694\n },\n {\n \"id\": \"escort\",\n \"seconds\": 6.50449\n },\n {\n \"id\": \"interrupt\",\n \"seconds\": 6.948571\n },\n {\n \"id\": \"sequence\",\n \"seconds\": 6.4\n },\n {\n \"id\": \"wires\",\n \"seconds\": 7.183673\n },\n {\n \"id\": \"jump\",\n \"seconds\": 5.877551\n },\n {\n \"id\": \"parallax\",\n \"seconds\": 5.746939\n },\n {\n \"id\": \"reveal\",\n \"seconds\": 5.198367\n },\n {\n \"id\": \"drift\",\n \"seconds\": 9.613061\n },\n {\n \"id\": \"hang\",\n \"seconds\": 7.053061\n },\n {\n \"id\": \"header\",\n \"seconds\": 5.616327\n },\n {\n \"id\": \"fold\",\n \"seconds\": 7.78449\n },\n {\n \"id\": \"sheet\",\n \"seconds\": 6.269388\n },\n {\n \"id\": \"mockup\",\n \"seconds\": 8.437551\n },\n {\n \"id\": \"camera\",\n \"seconds\": 6.870204\n },\n {\n \"id\": \"long-take\",\n \"seconds\": 7.183673\n },\n {\n \"id\": \"sylva\",\n \"seconds\": 8.672653\n },\n {\n \"id\": \"subdivision\",\n \"seconds\": 6.217143\n },\n {\n \"id\": \"rack\",\n \"seconds\": 7.967347\n },\n {\n \"id\": \"grip\",\n \"seconds\": 7.053061\n },\n {\n \"id\": \"drag\",\n \"seconds\": 5.982041\n },\n {\n \"id\": \"slides\",\n \"seconds\": 7.549388\n },\n {\n \"id\": \"presentation\",\n \"seconds\": 6.922449\n },\n {\n \"id\": \"towers\",\n \"seconds\": 8.96\n },\n {\n \"id\": \"sagrada\",\n \"seconds\": 8.907755\n },\n {\n \"id\": \"playhead\",\n \"seconds\": 7.549388\n },\n {\n \"id\": \"build-order\",\n \"seconds\": 10.710204\n }\n ],\n \"narrationPath\": \"widget-tour/narration-george-fast-v1\"\n}\n";
13
13
  //#endregion
14
14
  //#region ../choreo-gallery/realm/demo.gts?raw
15
- var demo_default = "import BooleanField from '@cardstack/base/boolean';\nimport {\n CardDef,\n Component,\n contains,\n containsMany,\n field,\n FieldDef,\n linksTo,\n} from '@cardstack/base/card-api';\nimport StringField from '@cardstack/base/string';\nimport TextAreaField from '@cardstack/base/text-area';\nimport type { Join } from '@cardstack/choreo/film';\nimport { rri } from '@cardstack/runtime-common';\nimport { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { tracked } from '@glimmer/tracking';\nimport type { ComponentLike } from '@glint/template';\nimport { modifier } from 'ember-modifier';\n\nimport type { ChoreoGallery } from './gallery';\nimport type { FilmLink } from './lib/film-link';\nimport { labSlug, LabState } from './lib/lab';\nimport { restWhenOff } from './lib/onstage';\nimport { openThroughHost, ownClicks, ownControlClicks } from './lib/pointer';\nimport { realmFile } from './lib/realm-url';\nimport { requestTheater, takeTheaterRequest, Theater } from './lib/theater';\nimport { ChoreoRoot } from './shell/choreo-root';\nimport { DemoPage } from './shell/demo-page';\nimport { DemoStage } from './shell/demo-stage';\nimport { GuideLink } from './shell/guide-link';\nimport { LabPanel } from './shell/lab-panel';\nimport { LabToggle } from './shell/lab-toggle';\nimport { SiteFrame } from './shell/site-frame';\n\n/** the gallery's sections, in the order its filters list them */\nexport const GROUPS = [\n 'Animate',\n 'Layout',\n 'Drag',\n 'Scroll',\n 'Choreo',\n '3D',\n /* FILM holds the pictures built on `<Film>`. They are 3D and Choreo and\n timeline work at once, and filing them under any one of those buries\n them — a film is its own kind of thing. */\n 'Film',\n 'Timeline',\n] as const;\n\nexport type DemoGroup = (typeof GROUPS)[number];\n\n/** what a demo's stage is told about where it is rendering */\nexport interface StageSignature {\n Args: {\n /** `tile` in the gallery grid and summaries; `stage` on the demo page */\n face?: 'stage' | 'tile';\n /** the demo page's line to its film, for a film stage to attach to */\n filmLink?: FilmLink;\n /** opens the demo card's page in theater, from a tile */\n open?: () => void;\n /** the demo page's theater, for a film stage to enter and leave */\n theater?: Theater;\n };\n}\n\n/** what a demo's notes are told: how to play a join over the stage's film */\nexport interface NotesSignature {\n Args: {\n /** set while a film is attached to the demo page's stage */\n preview?: (join: Join) => void;\n };\n}\n\n/**\n * The shape of the demo page's well, for a stage composed for a particular\n * frame: `wide` is a film composed about sixteen by nine, `tall` a film whose\n * subject runs to the top of the frame, `laptop` a wide, short device that\n * wants headroom above it and floor beneath, and `phone` a portrait device.\n * The default well suits a demo that fills its width.\n */\nexport type StageWell = 'laptop' | 'phone' | 'tall' | 'wide';\n\n/** One step of a demo's walkthrough: a sentence of why, then the code. */\nexport class WalkthroughStep extends FieldDef {\n static displayName = 'Walkthrough Step';\n @field label = contains(StringField);\n @field note = contains(TextAreaField);\n @field source = contains(TextAreaField);\n}\n\n/**\n * What a demo teaches, reviewed against its source: the concept, why it\n * works, something to try, the trap, and what it combines with. `guide` names\n * the concept guide the demo belongs to.\n */\nexport class DemoLesson extends FieldDef {\n static displayName = 'Demo Lesson';\n @field guide = contains(StringField);\n @field concept = contains(StringField);\n @field why = contains(TextAreaField);\n @field experiment = contains(TextAreaField);\n @field pitfall = contains(TextAreaField);\n @field combine = contains(TextAreaField);\n}\n\n/** the neighbours of a demo in its gallery's catalog */\nexport interface Near {\n next?: GalleryDemo;\n prev?: GalleryDemo;\n}\n\n/**\n * The demo's page, wherever it opens: in a stack over the gallery, as a\n * published page of its own, or on its own in the host. It wears the site's\n * frame, and its pager opens the demos either side of it in the gallery's\n * order, through the host, as the gallery's tiles do.\n */\nclass Isolated extends Component<typeof GalleryDemo> {\n /** a film's page opens in theater when its tile asked for it */\n readonly theater = new Theater(\n takeTheaterRequest((this.args.model as GalleryDemo).slug),\n );\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get near(): Near {\n let demos = (this.demo.gallery as ChoreoGallery | undefined)?.demos ?? [];\n let index = demos.findIndex((demo) => demo?.id === this.demo.id);\n if (index < 0) {\n return {};\n }\n return {\n next: demos[index + 1] ?? undefined,\n prev: index > 0 ? (demos[index - 1] ?? undefined) : undefined,\n };\n }\n\n get open() {\n let viewCard = this.args.viewCard;\n return viewCard\n ? (demo: GalleryDemo) => viewCard(demo, 'isolated')\n : undefined;\n }\n\n <template>\n <SiteFrame @theater={{this.theater}}>\n <DemoPage\n @demo={{this.demo}}\n @near={{this.near}}\n @onOpen={{this.open}}\n @theater={{this.theater}}\n />\n </SiteFrame>\n </template>\n}\n\n/**\n * The demo where a document embeds it, as a guide does: the live stage, what\n * it is, its guide, and the lab, opened under the stage.\n */\nclass Embedded extends Component<typeof GalleryDemo> {\n readonly lab = new LabState();\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get labSlug(): string | undefined {\n return labSlug(this.demo);\n }\n\n get labOpen(): boolean {\n return this.lab.isOpen(this.labSlug);\n }\n\n toggleLab = () => this.lab.toggle(this.labSlug);\n\n <template>\n <ChoreoRoot class='tile'>\n <div class='tile-stage' {{ownClicks}}>\n {{#each (array this.lab.replays) key='@identity'}}\n <DemoStage @demo={{this.demo}} @face='tile' />\n {{/each}}\n </div>\n <div class='tile-meta'>\n <span class='tile-group'>{{@model.group}}</span>\n <span class='tile-title'>{{@model.title}}</span>\n <span class='tile-lede'>{{@model.lede}}</span>\n <span class='tile-links' {{ownClicks}}>\n {{#if this.labSlug}}\n <LabToggle @open={{this.labOpen}} @onToggle={{this.toggleLab}} />\n {{/if}}\n <GuideLink @demo={{this.demo}} />\n </span>\n </div>\n {{#if this.labOpen}}\n {{#if this.labSlug}}\n <div class='tile-lab' {{ownClicks}}>\n <LabPanel\n @slug={{this.labSlug}}\n @title={{@model.title}}\n @onReplay={{this.lab.replay}}\n />\n </div>\n {{/if}}\n {{/if}}\n </ChoreoRoot>\n <style scoped>\n .tile {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: var(--bg-elev);\n }\n\n .tile-stage {\n position: relative;\n height: 360px;\n container-name: platter;\n container-type: size;\n background: var(--bg);\n }\n\n .tile-meta {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 16px 18px 18px;\n border-top: 1px solid var(--line);\n }\n\n .tile-group {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .tile-title {\n font-family: var(--font-display);\n font-weight: 700;\n font-size: 1.2rem;\n letter-spacing: -0.03em;\n }\n\n .tile-lede {\n color: var(--ink-dim);\n font-size: 0.92rem;\n }\n\n .tile-links {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 8px 20px;\n margin-top: 4px;\n }\n\n .tile-lab {\n padding: 16px 18px 18px;\n border-top: 1px solid var(--line);\n }\n </style>\n </template>\n}\n\n/** the smallest box a tile boots its live stage in */\nconst STAGE_ROOM = { height: 300, width: 240 };\n\n/**\n * The demo as a tile: the live stage over what it is, with its guide. It is\n * the gallery's grid, and the host opens the demo card on a click anywhere on\n * it that the stage does not keep for itself.\n *\n * Each tile is a live demo, and a grid holds dozens, so a tile spends only\n * what it shows: its stage boots once the tile has room for it, rests while\n * it is scrolled out of view, and a film shows its poster rather than\n * running. In a box too small for a stage, as the host's lists and pickers\n * draw it, the tile is just the demo's name.\n */\nclass Fitted extends Component<typeof GalleryDemo> {\n @tracked roomy = false;\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get deepDive(): boolean {\n return Boolean((this.demo.constructor as typeof GalleryDemo).notes);\n }\n\n measure = modifier((element: HTMLElement) => {\n let observer = new ResizeObserver(([entry]) => {\n let { height, width } = entry!.contentRect;\n let roomy = height >= STAGE_ROOM.height && width >= STAGE_ROOM.width;\n if (roomy !== this.roomy) {\n this.roomy = roomy;\n }\n });\n observer.observe(element);\n return () => observer.disconnect();\n });\n\n /** a film's door: its page, opened straight into theater */\n get open() {\n let viewCard = this.args.viewCard;\n let slug = this.demo.slug;\n if (!viewCard || !slug) {\n return undefined;\n }\n return () => {\n requestTheater(slug);\n viewCard(this.demo, 'isolated');\n };\n }\n\n <template>\n <ChoreoRoot class='tile {{if this.roomy \"is-roomy\"}}' {{this.measure}}>\n {{#if this.roomy}}\n <div class='tile-stage' {{restWhenOff}} {{ownControlClicks}}>\n <DemoStage @demo={{this.demo}} @face='tile' @open={{this.open}} />\n </div>\n {{/if}}\n <div class='tile-meta'>\n <span class='tile-group'>\n {{@model.group}}\n {{#if this.deepDive}}\n <span class='tile-badge'>Deep Dive</span>\n {{/if}}\n </span>\n {{! the tile's one link, to the demo card: its name, and the way in for\n a keyboard, whose Enter the host takes as it takes a click }}\n <a\n class='tile-title'\n href={{@model.id}}\n {{on 'click' openThroughHost}}\n >{{@model.title}}</a>\n {{#if this.roomy}}\n <span class='tile-lede'>{{@model.lede}}</span>\n <span class='tile-links' {{ownClicks}}>\n <GuideLink @demo={{this.demo}} />\n </span>\n {{/if}}\n </div>\n </ChoreoRoot>\n <style scoped>\n .tile {\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n height: 100%;\n overflow: hidden;\n background:\n linear-gradient(\n 180deg,\n rgba(var(--surface-tint-rgb), 0.02),\n transparent 40%\n ),\n var(--bg-elev);\n }\n\n /* a NAMED size container, so a stage can ask about the platter\n specifically rather than whichever container is nearest */\n .tile-stage {\n position: relative;\n flex: 1;\n min-height: 0;\n container-name: platter;\n container-type: size;\n background:\n radial-gradient(\n 80% 70% at 50% 40%,\n rgba(255, 59, 31, 0.08),\n transparent 62%\n ),\n var(--bg);\n }\n\n /* A tile nobody can see holds its animations where they stand —\n paused, so coming back does not restart every loop */\n .tile-stage.is-resting,\n .tile-stage.is-resting :deep(*) {\n animation-play-state: paused !important;\n }\n\n .tile-meta {\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding: 10px 12px;\n overflow: hidden;\n }\n\n .tile.is-roomy .tile-meta {\n gap: 6px;\n padding: 16px 18px 16px;\n border-top: 1px solid var(--line);\n background: rgba(var(--surface-tint-rgb), 0.015);\n }\n\n .tile-group {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .tile.is-roomy .tile-group {\n font-size: 10px;\n }\n\n .tile-badge {\n display: inline-block;\n margin-left: 6px;\n padding: 1px 6px;\n font-size: 9px;\n font-family: var(--font-mono);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ember-ink);\n border: 1px solid color-mix(in srgb, var(--ember-ink) 55%, transparent);\n border-radius: 3px;\n vertical-align: middle;\n }\n\n .tile-title {\n color: inherit;\n text-decoration: none;\n font-family: var(--font-display);\n font-weight: 700;\n font-size: 1rem;\n letter-spacing: -0.03em;\n line-height: 1.1;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .tile.is-roomy .tile-title {\n font-size: 1.2rem;\n }\n\n @media (hover: hover) {\n .tile:hover .tile-title {\n color: var(--ember-ink);\n }\n }\n\n /* the link is the tile's focus, and the whole tile wears the ring: the\n tile clips what falls outside it, so the ring is drawn inside */\n .tile-title:focus-visible {\n outline: none;\n }\n\n .tile:has(.tile-title:focus-visible) {\n outline: 2px solid var(--focus-ring);\n outline-offset: -2px;\n }\n\n .tile-lede {\n color: var(--ink-dim);\n font-size: 0.92rem;\n }\n\n .tile-links {\n margin-top: 4px;\n }\n </style>\n </template>\n}\n\n/**\n * One entry in the gallery: its words and its code are this card's data; its\n * live stage and its long-form notes are components, supplied by a subclass\n * per demo as the class's `stage` and `notes`.\n */\nexport class GalleryDemo extends CardDef {\n static displayName = 'Choreo Demo';\n static prefersWideFormat = true;\n\n /** the live demo; undefined until the demo has a stage of its own */\n static stage: ComponentLike<StageSignature> | undefined;\n /** the shape of the demo page's well, when the stage needs its own */\n static well: StageWell | undefined;\n /** how the demo works, rendered under the usage example (a Deep Dive) */\n static notes: ComponentLike<NotesSignature> | undefined;\n\n /** the demo's id in the catalog, and the instance's filename */\n @field slug = contains(StringField);\n @field title = contains(StringField);\n /** one of GROUPS */\n @field group = contains(StringField);\n @field lede = contains(StringField);\n /** the API names the demo exercises, as the page's pills */\n @field apis = containsMany(StringField);\n /** the usage example under the stage */\n @field sample = contains(TextAreaField);\n /**\n * Whether the stage honours `setMotionSpeed`, and so gets the speed\n * control. Motion driven by the pointer, the scroll position or an\n * imperative `animate()` call has no transition to scale, so those opt out.\n */\n @field slowmo = contains(BooleanField);\n /** whether the stage has a theater to enter: the films and Sylva's world */\n @field theater = contains(BooleanField);\n /** the data that drives the demo, quoted under the usage example */\n @field walkthrough = containsMany(WalkthroughStep);\n @field lesson = contains(DemoLesson);\n\n /** the gallery that lists this demo, whose order the demo page pages in */\n @field gallery = linksTo(CardDef, {\n query: {\n filter: {\n on: { module: rri(realmFile('gallery')), name: 'ChoreoGallery' },\n eq: { 'demos.id': '$this.id' },\n },\n },\n });\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: GalleryDemo) {\n return this.title;\n },\n });\n @field cardDescription = contains(StringField, {\n computeVia: function (this: GalleryDemo) {\n return this.lede;\n },\n });\n\n static isolated = Isolated;\n static embedded = Embedded;\n static fitted = Fitted;\n}\n";
15
+ var demo_default = "import BooleanField from '@cardstack/base/boolean';\nimport {\n CardDef,\n Component,\n contains,\n containsMany,\n field,\n FieldDef,\n linksTo,\n} from '@cardstack/base/card-api';\nimport StringField from '@cardstack/base/string';\nimport TextAreaField from '@cardstack/base/text-area';\nimport type { Join } from '@cardstack/choreo/film';\nimport { rri } from '@cardstack/runtime-common';\nimport { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { tracked } from '@glimmer/tracking';\nimport type { ComponentLike } from '@glint/template';\nimport { modifier } from 'ember-modifier';\n\nimport type { ChoreoGallery } from './gallery';\nimport type { FilmLink } from './lib/film-link';\nimport { labSlug, LabState } from './lib/lab';\nimport { restWhenOff } from './lib/onstage';\nimport { openThroughHost, ownClicks, ownControlClicks } from './lib/pointer';\nimport { realmFile } from './lib/realm-url';\nimport { requestTheater, takeTheaterRequest, Theater } from './lib/theater';\nimport { ChoreoRoot } from './shell/choreo-root';\nimport { DemoPage } from './shell/demo-page';\nimport { DemoStage } from './shell/demo-stage';\nimport { GuideLink } from './shell/guide-link';\nimport { LabPanel } from './shell/lab-panel';\nimport { LabToggle } from './shell/lab-toggle';\nimport { SiteFrame } from './shell/site-frame';\n\n/** the gallery's sections, in the order its filters list them */\nexport const GROUPS = [\n 'Animate',\n 'Layout',\n 'Drag',\n 'Scroll',\n 'Choreo',\n '3D',\n /* FILM holds the pictures built on `<Film>`. They are 3D and Choreo and\n timeline work at once, and filing them under any one of those buries\n them — a film is its own kind of thing. */\n 'Film',\n 'Timeline',\n] as const;\n\nexport type DemoGroup = (typeof GROUPS)[number];\n\n/** what a demo's stage is told about where it is rendering */\nexport interface StageSignature {\n Args: {\n /** `tile` in the gallery grid and summaries; `stage` on the demo page */\n face?: 'stage' | 'tile';\n /** the demo page's line to its film, for a film stage to attach to */\n filmLink?: FilmLink;\n /** opens the demo card's page in theater, from a tile */\n open?: () => void;\n /** the demo page's theater, for a film stage to enter and leave */\n theater?: Theater;\n };\n}\n\n/** what a demo's notes are told: how to play a join over the stage's film */\nexport interface NotesSignature {\n Args: {\n /** set while a film is attached to the demo page's stage */\n preview?: (join: Join) => void;\n };\n}\n\n/**\n * The shape of the demo page's well, for a stage composed for a particular\n * frame: `wide` is a film composed about sixteen by nine, `tall` a film whose\n * subject runs to the top of the frame, `laptop` a wide, short device that\n * wants headroom above it and floor beneath, and `phone` a portrait device.\n * The default well suits a demo that fills its width.\n */\nexport type StageWell = 'laptop' | 'phone' | 'tall' | 'wide';\n\n/** One step of a demo's walkthrough: a sentence of why, then the code. */\nexport class WalkthroughStep extends FieldDef {\n static displayName = 'Walkthrough Step';\n @field label = contains(StringField);\n @field note = contains(TextAreaField);\n @field source = contains(TextAreaField);\n}\n\n/**\n * What a demo teaches, reviewed against its source: the concept, why it\n * works, something to try, the trap, and what it combines with. `guide` names\n * the concept guide the demo belongs to.\n */\nexport class DemoLesson extends FieldDef {\n static displayName = 'Demo Lesson';\n @field guide = contains(StringField);\n @field concept = contains(StringField);\n @field why = contains(TextAreaField);\n @field experiment = contains(TextAreaField);\n @field pitfall = contains(TextAreaField);\n @field combine = contains(TextAreaField);\n}\n\n/** the neighbours of a demo in its gallery's catalog */\nexport interface Near {\n next?: GalleryDemo;\n prev?: GalleryDemo;\n}\n\n/**\n * The demo's page, wherever it opens: in a stack over the gallery, as a\n * published page of its own, or on its own in the host. It wears the site's\n * frame, and its pager opens the demos either side of it in the gallery's\n * order, through the host, as the gallery's tiles do.\n */\nclass Isolated extends Component<typeof GalleryDemo> {\n /** a film's page opens in theater when its tile asked for it */\n readonly theater = new Theater(\n takeTheaterRequest((this.args.model as GalleryDemo).slug),\n );\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get near(): Near {\n let demos = (this.demo.gallery as ChoreoGallery | undefined)?.demos ?? [];\n let index = demos.findIndex((demo) => demo?.id === this.demo.id);\n if (index < 0) {\n return {};\n }\n return {\n next: demos[index + 1] ?? undefined,\n prev: index > 0 ? (demos[index - 1] ?? undefined) : undefined,\n };\n }\n\n get open() {\n let viewCard = this.args.viewCard;\n return viewCard\n ? (demo: GalleryDemo) => viewCard(demo, 'isolated')\n : undefined;\n }\n\n <template>\n <SiteFrame @theater={{this.theater}}>\n <DemoPage\n @demo={{this.demo}}\n @near={{this.near}}\n @onOpen={{this.open}}\n @theater={{this.theater}}\n />\n </SiteFrame>\n </template>\n}\n\n/**\n * The demo where a document embeds it, as a guide does: the live stage, what\n * it is, its guide, and the lab, opened under the stage.\n */\nclass Embedded extends Component<typeof GalleryDemo> {\n readonly lab = new LabState();\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get labSlug(): string | undefined {\n return labSlug(this.demo);\n }\n\n get labOpen(): boolean {\n return this.lab.isOpen(this.labSlug);\n }\n\n toggleLab = () => this.lab.toggle(this.labSlug);\n\n <template>\n <ChoreoRoot class='tile'>\n <div class='tile-stage' {{ownClicks}}>\n {{#each (array this.lab.replays) key='@identity'}}\n <DemoStage @demo={{this.demo}} @face='tile' />\n {{/each}}\n </div>\n <div class='tile-meta'>\n <span class='tile-group'>{{@model.group}}</span>\n <span class='tile-title'>{{@model.title}}</span>\n <span class='tile-lede'>{{@model.lede}}</span>\n <span class='tile-links' {{ownClicks}}>\n {{#if this.labSlug}}\n <LabToggle @open={{this.labOpen}} @onToggle={{this.toggleLab}} />\n {{/if}}\n <GuideLink @demo={{this.demo}} />\n </span>\n </div>\n {{#if this.labOpen}}\n {{#if this.labSlug}}\n <div class='tile-lab' {{ownClicks}}>\n <LabPanel\n @slug={{this.labSlug}}\n @title={{@model.title}}\n @onReplay={{this.lab.replay}}\n />\n </div>\n {{/if}}\n {{/if}}\n </ChoreoRoot>\n <style scoped>\n .tile {\n display: flex;\n flex-direction: column;\n overflow: hidden;\n background: var(--bg-elev);\n }\n\n .tile-stage {\n position: relative;\n height: 360px;\n container-name: platter;\n container-type: size;\n background: var(--bg);\n }\n\n .tile-meta {\n display: flex;\n flex-direction: column;\n gap: 6px;\n padding: 16px 18px 18px;\n border-top: 1px solid var(--line);\n }\n\n .tile-group {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .tile-title {\n font-family: var(--font-display);\n font-weight: 700;\n font-size: 1.2rem;\n letter-spacing: -0.03em;\n }\n\n .tile-lede {\n color: var(--ink-dim);\n font-size: 0.92rem;\n }\n\n .tile-links {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 8px 20px;\n margin-top: 4px;\n }\n\n .tile-lab {\n padding: 16px 18px 18px;\n border-top: 1px solid var(--line);\n }\n </style>\n </template>\n}\n\n/** the smallest box a tile boots its live stage in */\nconst STAGE_ROOM = { height: 300, width: 240 };\n\n/**\n * The demo as a tile: the live stage over what it is, with its guide. It is\n * the gallery's grid, and the host opens the demo card on a click anywhere on\n * it that the stage does not keep for itself.\n *\n * Each tile is a live demo, and a grid holds dozens, so a tile spends only\n * what it shows: its stage boots once the tile has room for it, rests while\n * it is scrolled out of view, and a film shows its poster rather than\n * running. In a box too small for a stage, as the host's lists and pickers\n * draw it, the tile is just the demo's name.\n */\nclass Fitted extends Component<typeof GalleryDemo> {\n @tracked roomy = false;\n\n get demo() {\n return this.args.model as GalleryDemo;\n }\n\n get deepDive(): boolean {\n return Boolean((this.demo.constructor as typeof GalleryDemo).notes);\n }\n\n measure = modifier((element: HTMLElement) => {\n let observer = new ResizeObserver(([entry]) => {\n let { height, width } = entry!.contentRect;\n // No box at all is not a small box: the host hides a covered stack\n // item's content with `display: none`. The stage stays mounted, resting\n // (see `restWhenOff`), and carries on from where it was when the card\n // shows again, rather than starting over.\n if (height === 0 && width === 0) {\n return;\n }\n let roomy = height >= STAGE_ROOM.height && width >= STAGE_ROOM.width;\n if (roomy !== this.roomy) {\n this.roomy = roomy;\n }\n });\n observer.observe(element);\n return () => observer.disconnect();\n });\n\n /** a film's door: its page, opened straight into theater */\n get open() {\n let viewCard = this.args.viewCard;\n let slug = this.demo.slug;\n if (!viewCard || !slug) {\n return undefined;\n }\n return () => {\n requestTheater(slug);\n viewCard(this.demo, 'isolated');\n };\n }\n\n <template>\n <ChoreoRoot class='tile {{if this.roomy \"is-roomy\"}}' {{this.measure}}>\n {{#if this.roomy}}\n <div class='tile-stage' {{restWhenOff}} {{ownControlClicks}}>\n <DemoStage @demo={{this.demo}} @face='tile' @open={{this.open}} />\n </div>\n {{/if}}\n <div class='tile-meta'>\n <span class='tile-group'>\n {{@model.group}}\n {{#if this.deepDive}}\n <span class='tile-badge'>Deep Dive</span>\n {{/if}}\n </span>\n {{! the tile's one link, to the demo card: its name, and the way in for\n a keyboard, whose Enter the host takes as it takes a click }}\n <a\n class='tile-title'\n href={{@model.id}}\n {{on 'click' openThroughHost}}\n >{{@model.title}}</a>\n {{#if this.roomy}}\n <span class='tile-lede'>{{@model.lede}}</span>\n <span class='tile-links' {{ownClicks}}>\n <GuideLink @demo={{this.demo}} />\n </span>\n {{/if}}\n </div>\n </ChoreoRoot>\n <style scoped>\n .tile {\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n height: 100%;\n overflow: hidden;\n background:\n linear-gradient(\n 180deg,\n rgba(var(--surface-tint-rgb), 0.02),\n transparent 40%\n ),\n var(--bg-elev);\n }\n\n /* a NAMED size container, so a stage can ask about the platter\n specifically rather than whichever container is nearest */\n .tile-stage {\n position: relative;\n flex: 1;\n min-height: 0;\n container-name: platter;\n container-type: size;\n background:\n radial-gradient(\n 80% 70% at 50% 40%,\n rgba(255, 59, 31, 0.08),\n transparent 62%\n ),\n var(--bg);\n }\n\n /* A tile nobody can see holds its animations where they stand —\n paused, so coming back does not restart every loop */\n .tile-stage.is-resting,\n .tile-stage.is-resting :deep(*) {\n animation-play-state: paused !important;\n }\n\n .tile-meta {\n display: flex;\n flex-direction: column;\n gap: 4px;\n padding: 10px 12px;\n overflow: hidden;\n }\n\n .tile.is-roomy .tile-meta {\n gap: 6px;\n padding: 16px 18px 16px;\n border-top: 1px solid var(--line);\n background: rgba(var(--surface-tint-rgb), 0.015);\n }\n\n .tile-group {\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .tile.is-roomy .tile-group {\n font-size: 10px;\n }\n\n .tile-badge {\n display: inline-block;\n margin-left: 6px;\n padding: 1px 6px;\n font-size: 9px;\n font-family: var(--font-mono);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ember-ink);\n border: 1px solid color-mix(in srgb, var(--ember-ink) 55%, transparent);\n border-radius: 3px;\n vertical-align: middle;\n }\n\n .tile-title {\n color: inherit;\n text-decoration: none;\n font-family: var(--font-display);\n font-weight: 700;\n font-size: 1rem;\n letter-spacing: -0.03em;\n line-height: 1.1;\n overflow: hidden;\n text-overflow: ellipsis;\n }\n\n .tile.is-roomy .tile-title {\n font-size: 1.2rem;\n }\n\n @media (hover: hover) {\n .tile:hover .tile-title {\n color: var(--ember-ink);\n }\n }\n\n /* the link is the tile's focus, and the whole tile wears the ring: the\n tile clips what falls outside it, so the ring is drawn inside */\n .tile-title:focus-visible {\n outline: none;\n }\n\n .tile:has(.tile-title:focus-visible) {\n outline: 2px solid var(--ring);\n outline-offset: -2px;\n }\n\n .tile-lede {\n color: var(--ink-dim);\n font-size: 0.92rem;\n }\n\n .tile-links {\n margin-top: 4px;\n }\n </style>\n </template>\n}\n\n/**\n * One entry in the gallery: its words and its code are this card's data; its\n * live stage and its long-form notes are components, supplied by a subclass\n * per demo as the class's `stage` and `notes`.\n */\nexport class GalleryDemo extends CardDef {\n static displayName = 'Choreo Demo';\n static prefersWideFormat = true;\n\n /** the live demo; undefined until the demo has a stage of its own */\n static stage: ComponentLike<StageSignature> | undefined;\n /** the shape of the demo page's well, when the stage needs its own */\n static well: StageWell | undefined;\n /** how the demo works, rendered under the usage example (a Deep Dive) */\n static notes: ComponentLike<NotesSignature> | undefined;\n\n /** the demo's id in the catalog, and the instance's filename */\n @field slug = contains(StringField);\n @field title = contains(StringField);\n /** one of GROUPS */\n @field group = contains(StringField);\n @field lede = contains(StringField);\n /** the API names the demo exercises, as the page's pills */\n @field apis = containsMany(StringField);\n /** the usage example under the stage */\n @field sample = contains(TextAreaField);\n /**\n * Whether the stage honours `setMotionSpeed`, and so gets the speed\n * control. Motion driven by the pointer, the scroll position or an\n * imperative `animate()` call has no transition to scale, so those opt out.\n */\n @field slowmo = contains(BooleanField);\n /** whether the stage has a theater to enter: the films and Sylva's world */\n @field theater = contains(BooleanField);\n /** the data that drives the demo, quoted under the usage example */\n @field walkthrough = containsMany(WalkthroughStep);\n @field lesson = contains(DemoLesson);\n\n /** the gallery that lists this demo, whose order the demo page pages in */\n @field gallery = linksTo(CardDef, {\n query: {\n filter: {\n on: { module: rri(realmFile('gallery')), name: 'ChoreoGallery' },\n eq: { 'demos.id': '$this.id' },\n },\n },\n });\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: GalleryDemo) {\n return this.title;\n },\n });\n @field cardDescription = contains(StringField, {\n computeVia: function (this: GalleryDemo) {\n return this.lede;\n },\n });\n\n static isolated = Isolated;\n static embedded = Embedded;\n static fitted = Fitted;\n}\n";
16
16
  //#endregion
17
17
  //#region ../choreo-gallery/realm/demos/build-order.json?raw
18
18
  var build_order_default$2 = "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"slug\": \"build-order\",\n \"title\": \"Build order\",\n \"group\": \"Timeline\",\n \"lede\": \"Keynote’s build inspector, wired to the site’s own logo. Retime it while it runs.\",\n \"apis\": [\"c.Sequence\", \"@name\", \"at()/after()\", \"@by\", \"c.run\"],\n \"sample\": \"// A build order, not a timeline. Nothing here is a timecode: a build\\n// names a part, an effect, and when it goes relative to the build above\\n// it — and the score is the template. `with` anchors on the previous\\n// build's START, `after` on its end; the compiler resolves the rest.\\n<c.Sequence>\\n <c.Tween @name='b1' @of={{c.id 'plate'}}\\n @opacity={{array 0 1 1 1}} @x={{array -38 0}}\\n @ease='easeOut' @duration={{0.62}} />\\n <c.Tween @name='b2' @at={{at 'b1'}} @delay={{0.14}}\\n @of={{c.id 'tail'}} @pathLength={{array 0 1}} @duration={{0.52}} />\\n <c.Tween @name='b3' @at={{after 'b2'}}\\n @of={{c.id 'head'}} @pathLength={{array 0 1}} @duration={{0.52}} />\\n …\\n <c.Tween @name='b7' @at={{after 'b6'}} @delay={{0.06}}\\n @of={{c.id 'word'}} @by='character' // ← a second timeline, inside\\n @opacity={{array 0 1 1}} @scale={{array 0.78 1}} @y={{array 18 0}}\\n @ease='easeOut' @duration={{0.76}} />\\n</c.Sequence>\\n\\n// An effect is nothing but keyframe values and an easing: Pop is a scale\\n// through backOut, and a build-in's leading opacity frames are Keynote's\\n// \\\"builds in\\\" — pinned hidden from the run's start until its window.\\n\\n// The transport holds the run. Play, pause and the scrubber share one\\n// clock: `time` is settable in either direction, and a scrubbed frame\\n// is a computed still.\\nc.run.pause();\\nc.run.time = 1.4; // the scrubber — and an edit replays the pass and\\nc.run.play(); // puts the new run back at the SAME t, so retiming\\n // build 2 while parked at 1.4s shows what 1.4s now is.\\n\\n// The bars on the rail are read back from run.cues — the compiler's\\n// resolved answer, never a second copy of the schedule.\",\n \"slowmo\": true,\n \"theater\": false,\n \"walkthrough\": [],\n \"lesson\": {\n \"guide\": \"film-schedule\",\n \"concept\": \"Named dependencies express intent better than absolute times\",\n \"why\": \"The builds name one another with with and after relationships. The compiler resolves start times, and the transport reads those times back instead of recomputing a parallel schedule.\",\n \"experiment\": \"Lengthen an earlier build and inspect the dependent starts. Compare Show each beat with Rapid assembly while keeping the dependency graph unchanged.\",\n \"pitfall\": \"Maintaining manual timestamps beside the template causes the displayed timeline to disagree with the actual score.\",\n \"combine\": \"Combine with presentation gates when some boundaries wait for a person and others follow named build dependencies.\"\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": \"../theme\"\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../stages/build-order\",\n \"name\": \"BuildOrderDemo\"\n }\n }\n }\n}\n";
@@ -186,7 +186,7 @@ var highlight_default = "import { htmlSafe } from '@ember/template';\n\nconst KE
186
186
  var lab_default = "import { tracked } from '@glimmer/tracking';\nimport type { DialConfig, DialValue } from 'dialkit/vanilla';\nimport { DialStore } from 'dialkit/vanilla';\nimport { postRender, retime } from 'glimmer-motion';\n\nimport { Dial, type DialTune } from './dial';\nimport { announceRestartedAnimations } from './onstage';\nimport { demoPresets } from './presets';\nimport { demoTuning } from './tuning';\n\n/**\n * The key a demo's lab tunes it under: its slug. A demo without one has no\n * lab, and neither do the films, which play in documents of their own the\n * lab can't reach.\n */\nexport function labSlug(demo: {\n slug?: string | null;\n theater?: boolean | null;\n}): string | undefined {\n return demo.theater !== true && demo.slug ? demo.slug : undefined;\n}\n\n/**\n * What a view showing a demo keeps for its lab: which demo the lab is open\n * on, and how many times its stage has been replayed. The lab belongs to the\n * demo it was opened on, so a view that moves to another demo shows it\n * closed.\n */\nexport class LabState {\n @tracked private openOn: string | null = null;\n /** counts replays: each one mounts the stage afresh */\n @tracked replays = 0;\n\n isOpen(slug: string | undefined): boolean {\n return slug !== undefined && this.openOn === slug;\n }\n\n toggle = (slug: string | undefined) => {\n this.openOn = this.isOpen(slug) ? null : (slug ?? null);\n };\n\n replay = () => {\n this.replays++;\n };\n}\n\n/** how many labs are open on each demo, by its slug */\nconst openLabs = new Map<string, number>();\n\n/** the demos with a retime already waiting for the render to land */\nconst retiming = new Set<string>();\n\n/**\n * Retime the motion running on every stage showing a demo, once the render\n * that delivers the demo's new values has landed. An edit reaches the stage\n * as it renders, but motion already running reads its timing when it starts:\n * this restarts what the edit retimed, keeping each loop's place in its\n * cycle. A Choreo timeline needs nothing here, because an edited timeline\n * replaces its run, and the new run carries its loops on.\n *\n * A dragged control edits many times a second; the edits that land in one\n * render share one retime. A stage at rest offscreen is told, so it can rest\n * the animations the retime started.\n */\nfunction retimeStages(slug: string) {\n if (retiming.has(slug)) {\n return;\n }\n retiming.add(slug);\n postRender(() => {\n retiming.delete(slug);\n for (let stage of document.querySelectorAll(\n `[data-demo-tuning=\"${CSS.escape(slug)}\"]`,\n )) {\n retime(stage);\n announceRestartedAnimations(stage);\n }\n });\n}\n\n/**\n * A demo's tuning on a dialkit panel: the lab.\n *\n * The panel's controls are the values the demo registers through\n * `demoTuning(slug)` as it renders, so the panel grows as the stage does. An\n * edit on the panel is written back into `demoTuning(slug).values`, where the\n * demo reads it. Only the values that differ from the demo's own are written,\n * so an untouched spring comes back from `tuneMotion` as the same object and\n * never replays a scene.\n *\n * An edit retimes motion the stage already has running, so a loop changes\n * speed where it is rather than on the next replay.\n *\n * Every lab open on one demo drives the same panel, so they show the same\n * values. The panel is neither persisted nor retained: closing the demo's\n * last lab puts the demo back as it was written.\n */\nexport class DemoLab {\n readonly dial: Dial<DialConfig>;\n /** bumped as the demo registers controls, which the presets depend on */\n @tracked private registered = 0;\n\n private slug: string;\n private stop: () => void;\n private writing = false;\n private gone = false;\n\n constructor(slug: string, name: string) {\n this.slug = slug;\n openLabs.set(slug, (openLabs.get(slug) ?? 0) + 1);\n this.dial = new Dial(`lab:${slug}`, name, this.config(), {\n persist: false,\n retain: false,\n });\n this.state.listeners.add(this.refresh);\n this.stop = DialStore.subscribe(this.dial.panelId, this.write);\n this.write();\n }\n\n private get state() {\n return demoTuning(this.slug);\n }\n\n private config(): DialConfig {\n return { ...this.state.definitions };\n }\n\n /**\n * The demo's authored looks whose every control the demo has registered;\n * the panel offers them as its shipped tunes.\n */\n get presets(): DialTune[] {\n void this.registered;\n let definitions = this.state.definitions;\n return (demoPresets[this.slug] ?? []).filter((preset) =>\n Object.keys(preset.values).every((key) => key in definitions),\n );\n }\n\n private refresh = () => {\n if (this.gone) {\n return;\n }\n this.dial.configure(this.config());\n this.registered += 1;\n };\n\n /**\n * A store notification can arrive inside a render pass (a panel registering\n * notifies its subscribers), and the values it writes are tracked state the\n * stage has already read in that pass, so the write waits for the pass to\n * close.\n */\n private write = () => {\n if (this.writing || this.gone) {\n return;\n }\n this.writing = true;\n queueMicrotask(() => {\n this.writing = false;\n if (this.gone) {\n return;\n }\n let changed = changedValues(\n this.state.definitions,\n DialStore.getValues(this.dial.panelId),\n );\n if (JSON.stringify(this.state.values) !== JSON.stringify(changed)) {\n this.state.values = changed;\n retimeStages(this.slug);\n }\n });\n };\n\n teardown() {\n this.gone = true;\n this.state.listeners.delete(this.refresh);\n this.stop();\n this.dial.teardown();\n DialStore.unregisterPanel(this.dial.panelId);\n let open = (openLabs.get(this.slug) ?? 1) - 1;\n if (open > 0) {\n openLabs.set(this.slug, open);\n } else {\n openLabs.delete(this.slug);\n if (Object.keys(this.state.values).length) {\n this.state.values = {};\n retimeStages(this.slug);\n }\n }\n }\n}\n\n/**\n * The panel's values that differ from the demo's own, by control. A control's\n * own value is its definition, or the first entry of a `[value, min, max,\n * step]` range. An object value differs when any field on either side does,\n * so a field the editor adds (a spring's `mass` beside an authored stiffness\n * and damping) counts as an edit.\n */\nexport function changedValues(\n definitions: DialConfig,\n values: Record<string, DialValue>,\n): Record<string, DialValue> {\n let changed: Record<string, DialValue> = {};\n for (let [key, value] of Object.entries(values)) {\n let definition = definitions[key];\n if (definition === undefined) {\n continue;\n }\n let own = Array.isArray(definition) ? definition[0] : definition;\n let same =\n own && typeof own === 'object' && value && typeof value === 'object'\n ? sameFields(\n own as Record<string, unknown>,\n value as Record<string, unknown>,\n )\n : own === value;\n if (!same) {\n changed[key] = value;\n }\n }\n return changed;\n}\n\nfunction sameFields(a: Record<string, unknown>, b: Record<string, unknown>) {\n return [...new Set([...Object.keys(a), ...Object.keys(b)])].every(\n (field) => JSON.stringify(a[field]) === JSON.stringify(b[field]),\n );\n}\n";
187
187
  //#endregion
188
188
  //#region ../choreo-gallery/realm/lib/onstage.ts?raw
189
- var onstage_default = "import { modifier } from 'ember-modifier';\n\n/**\n * Tell a demo whether anyone can see it.\n *\n * The gallery renders forty-two live demos at once. Most of them are cheap —\n * they are a handful of elements that move when you press something and sit\n * still otherwise. A few are not: Long Take and the mockup each own a WebGL\n * context and a render loop, and Drift runs a fixed-step integrator at 120Hz\n * with a canvas under it. Three of those scrolled off the top of the page are\n * three render loops burning frames for nobody.\n *\n * The cheapest fix would be to unmount them, and it is the wrong one: a stage\n * that unmounts loses the tune you gave it, the marks you laid and the laps you\n * set, and scrolling past your own work should not delete it. So the stage\n * stays mounted and stops WORKING — which is also what makes this safe to reach\n * for. Nothing here changes what a demo is, only whether it is running.\n *\n * `rootMargin` is generous on purpose. A demo that starts the instant its top\n * edge crosses the fold arrives already moving; one that waits for the exact\n * boundary arrives frozen and then jerks into life a frame later, which reads\n * as a bug rather than as an optimisation. The margin is measured against the\n * element that actually scrolls the stage — in the host that is the stack item\n * the card sits in, not the viewport — so a stage just below the item's edge is\n * already running when it scrolls in.\n */\n/**\n * The nearest ancestor set to scroll, taken to be the one that does. That\n * holds where the gallery renders: a card's stack item is sized to its pane\n * and scrolls its content. The choice is made when the stage mounts, before\n * the content above and below it has necessarily rendered, so it goes by the\n * overflow style rather than by whether the box overflows yet. An ancestor\n * set to scroll that instead grows with its content clips nothing, and every\n * stage under it counts as visible: they keep running, which costs frames but\n * never freezes a demo someone is looking at.\n */\nexport function scrollRoot(el: Element): Element | null {\n let node = el.parentElement;\n while (node) {\n const { overflowY } = getComputedStyle(node);\n if (overflowY === 'auto' || overflowY === 'scroll') {\n return node;\n }\n node = node.parentElement;\n }\n return null;\n}\n\nfunction roomOwner(el: Element): HTMLElement | null {\n const local = el.closest<HTMLElement>('[data-widget-active]');\n if (local) {\n return local;\n }\n try {\n const frame = el.ownerDocument.defaultView?.frameElement;\n return frame ? roomOwner(frame) : null;\n } catch {\n return null;\n }\n}\n\n/** Room focus overrides viewport visibility; the ordinary 2D gallery is unchanged. */\nexport function observeStage(\n el: Element,\n tell: (visible: boolean) => void,\n options: IntersectionObserverInit = { rootMargin: '200px' },\n) {\n const owner = roomOwner(el);\n let intersecting = false;\n let previous: boolean | undefined;\n const update = () => {\n const visible = owner\n ? owner.dataset.widgetActive === 'true'\n : intersecting;\n if (visible !== previous) {\n previous = visible;\n tell(visible);\n }\n };\n const io = new IntersectionObserver(\n (entries) => {\n intersecting = entries.some((entry) => entry.isIntersecting);\n update();\n },\n { root: scrollRoot(el), ...options },\n );\n const changes = new MutationObserver(update);\n if (owner) {\n changes.observe(owner, {\n attributes: true,\n attributeFilter: ['data-widget-active'],\n });\n }\n let connected = true;\n io.observe(el);\n // Visibility may update tracked demo state; run after Glimmer's render pass.\n queueMicrotask(() => {\n if (connected) {\n update();\n }\n });\n return {\n disconnect() {\n connected = false;\n io.disconnect();\n changes.disconnect();\n },\n };\n}\n\nexport const onStage = modifier(\n (el: HTMLElement, [tell]: [(visible: boolean) => void]) => {\n const observer = observeStage(el, tell);\n return () => {\n observer.disconnect();\n tell(false);\n };\n },\n);\n\n/**\n * Stop a card's CSS animations while nobody can see it.\n *\n * `onStage` above is for demos that own a loop and can be told to stop. This is\n * for the other twenty-odd, which own no loop at all and animate purely in CSS\n * — and between them, on an idle gallery, they were running twenty-five\n * animations at once with fourteen of those coming from one stage. Nothing was\n * broken; it is simply that a page of forty-two live demos is a page of\n * forty-two things that never rest, and the crossing has to share a thread with\n * all of them.\n *\n * It toggles a class rather than touching the animations, because\n * `animation-play-state` is inherited-by-selector and pauses a whole subtree in\n * one declaration — including animations that were declared by a stage this\n * file has never heard of. Pausing is also the right verb: a paused animation\n * keeps its position, so a card scrolled away and back does not restart its\n * decoration from frame zero, which would be a worse artefact than the cost it\n * saves.\n *\n * Web Animations — Motion's own — need the second half, because\n * `animation-play-state` cannot reach them: they are script-driven and have no\n * CSS animation to pause. The ordinary gallery pauses only endless effects,\n * allowing finite transitions to settle offscreen. The 3D room instead holds\n * every effect at its current frame until that one tile receives camera focus.\n *\n * `iterations` reads as `Infinity` or, for some engines, `null` — both mean the\n * same thing here and both are checked.\n */\nconst endless = (a: Animation) => {\n const n = a.effect?.getTiming?.().iterations;\n return n === Infinity || n === null;\n};\n\n// Batch geometry/style reads for every affected tile before changing any of\n// their animation states. One subtree scan per frame, never one per style write.\nconst pendingRest = new Map<HTMLElement, (animations: Animation[]) => void>();\nlet restFrame = 0;\nfunction scheduleRest(\n el: HTMLElement,\n apply: (animations: Animation[]) => void,\n) {\n pendingRest.set(el, apply);\n if (restFrame) {\n return;\n }\n restFrame = requestAnimationFrame(() => {\n restFrame = 0;\n const reads = [...pendingRest].map(([element, fn]) => ({\n fn,\n animations: element.getAnimations({ subtree: true }),\n }));\n pendingRest.clear();\n for (const { fn, animations } of reads) {\n fn(animations);\n }\n });\n}\n\n/**\n * Something restarted animations inside `el` without rendering: the lab,\n * retiming a stage. A resting card has to hear about it, because the\n * animations it paused were cancelled and their replacements are running.\n */\nconst RESTARTED = 'choreo-gallery:animations-restarted';\n\nexport function announceRestartedAnimations(el: Element) {\n el.dispatchEvent(new CustomEvent(RESTARTED, { bubbles: true }));\n}\n\nexport const restWhenOff = modifier((el: HTMLElement) => {\n const room = !!roomOwner(el);\n let active = false;\n const paused = new Set<Animation>();\n let closed = false;\n const apply = (animations: Animation[]) => {\n if (closed) {\n return;\n }\n el.classList.toggle('is-resting', !active);\n for (const animation of animations) {\n if (!room && !endless(animation)) {\n continue;\n }\n if (!active && animation.playState === 'running') {\n animation.pause();\n paused.add(animation);\n }\n }\n if (active) {\n for (const animation of paused) {\n if (animation.playState === 'paused') {\n animation.play();\n }\n }\n paused.clear();\n }\n };\n const settle = () => scheduleRest(el, apply);\n const observer = observeStage(el, (visible) => {\n active = visible;\n settle();\n });\n // Catch animations created by a pending render while this card is frozen.\n const changes = new MutationObserver(() => {\n if (!active) {\n settle();\n }\n });\n changes.observe(el, {\n childList: true,\n subtree: true,\n attributes: room,\n attributeFilter: room ? ['style'] : undefined,\n });\n el.addEventListener('animationstart', settle);\n // …and animations restarted in place, which change no DOM\n el.addEventListener(RESTARTED, settle);\n return () => {\n observer.disconnect();\n changes.disconnect();\n el.removeEventListener('animationstart', settle);\n el.removeEventListener(RESTARTED, settle);\n pendingRest.delete(el);\n active = true;\n apply([]);\n closed = true;\n };\n});\n\nexport default onStage;\n";
189
+ var onstage_default = "import { modifier } from 'ember-modifier';\n\n/**\n * Tell a demo whether anyone can see it.\n *\n * The gallery renders forty-two live demos at once. Most of them are cheap —\n * they are a handful of elements that move when you press something and sit\n * still otherwise. A few are not: Long Take and the mockup each own a WebGL\n * context and a render loop, and Drift runs a fixed-step integrator at 120Hz\n * with a canvas under it. Three of those scrolled off the top of the page are\n * three render loops burning frames for nobody.\n *\n * The cheapest fix would be to unmount them, and it is the wrong one: a stage\n * that unmounts loses the tune you gave it, the marks you laid and the laps you\n * set, and scrolling past your own work should not delete it. So the stage\n * stays mounted and stops WORKING — which is also what makes this safe to reach\n * for. Nothing here changes what a demo is, only whether it is running.\n *\n * `rootMargin` is generous on purpose. A demo that starts the instant its top\n * edge crosses the fold arrives already moving; one that waits for the exact\n * boundary arrives frozen and then jerks into life a frame later, which reads\n * as a bug rather than as an optimisation. The margin is measured against the\n * element that actually scrolls the stage — in the host that is the stack item\n * the card sits in, not the viewport — so a stage just below the item's edge is\n * already running when it scrolls in.\n */\n/**\n * The nearest ancestor set to scroll, taken to be the one that does. That\n * holds where the gallery renders: a card's stack item is sized to its pane\n * and scrolls its content. The choice is made when the stage mounts, before\n * the content above and below it has necessarily rendered, so it goes by the\n * overflow style rather than by whether the box overflows yet. An ancestor\n * set to scroll that instead grows with its content clips nothing, and every\n * stage under it counts as visible: they keep running, which costs frames but\n * never freezes a demo someone is looking at.\n */\nexport function scrollRoot(el: Element): Element | null {\n let node = el.parentElement;\n while (node) {\n const { overflowY } = getComputedStyle(node);\n if (overflowY === 'auto' || overflowY === 'scroll') {\n return node;\n }\n node = node.parentElement;\n }\n return null;\n}\n\nfunction roomOwner(el: Element): HTMLElement | null {\n const local = el.closest<HTMLElement>('[data-widget-active]');\n if (local) {\n return local;\n }\n try {\n const frame = el.ownerDocument.defaultView?.frameElement;\n return frame ? roomOwner(frame) : null;\n } catch {\n return null;\n }\n}\n\n/** The element, then each frame element it renders inside, outward. */\nfunction* outward(el: Element): Generator<Element> {\n let node: Element | null = el;\n while (node) {\n yield node;\n try {\n node = node.ownerDocument.defaultView?.frameElement ?? null;\n } catch {\n node = null;\n }\n }\n}\n\n/**\n * Whether something the stage cannot see past covers it.\n *\n * An intersection observer answers \"is it in the scrolled-to part of the\n * page\", which is not \"can anyone see it\": on a published site, a card opened\n * from the gallery is a stack item over it, and the grid stays rendered\n * underneath, in view, behind the scrim. The host makes a card it covers\n * `inert`. That is the platform's word for \"not part of what the user is\n * working with\", it reaches every engine, and it changes nothing about the\n * box, so it is read directly rather than inferred from geometry. (The\n * occlusion test that `IntersectionObserver`'s `trackVisibility` offers is\n * Chromium's alone, and it also calls a stage hidden for any transform or\n * opacity on the way up, which the 3D room is made of.)\n */\nfunction covered(el: Element): boolean {\n for (const node of outward(el)) {\n if (node.closest('[inert]')) {\n return true;\n }\n }\n return false;\n}\n\n/**\n * One watcher per document for `inert` coming and going, shared by every\n * stage in it: a page of forty-odd stages is otherwise forty-odd observers\n * on the same subtree, each consulted on every attribute any of them writes.\n */\nconst inertWatchers = new Map<\n Document,\n { listeners: Set<() => void>; observer: MutationObserver }\n>();\n\nfunction watchCover(el: Element, listener: () => void): () => void {\n const docs = [...outward(el)].map((node) => node.ownerDocument);\n for (const doc of docs) {\n let watcher = inertWatchers.get(doc);\n if (!watcher) {\n const listeners = new Set<() => void>();\n const observer = new MutationObserver(() => {\n for (const fn of [...listeners]) {\n fn();\n }\n });\n observer.observe(doc.documentElement, {\n attributes: true,\n attributeFilter: ['inert'],\n subtree: true,\n });\n watcher = { observer, listeners };\n inertWatchers.set(doc, watcher);\n }\n watcher.listeners.add(listener);\n }\n return () => {\n for (const doc of docs) {\n const watcher = inertWatchers.get(doc);\n if (!watcher) {\n continue;\n }\n watcher.listeners.delete(listener);\n if (watcher.listeners.size === 0) {\n watcher.observer.disconnect();\n inertWatchers.delete(doc);\n }\n }\n };\n}\n\n/**\n * Room focus overrides viewport visibility; the ordinary 2D gallery is\n * unchanged. Either way, a covered stage is off.\n *\n * Inside the 3D room, cover is asked of the room's tile rather than of the\n * stage: the room makes a tile's own content `inert` while it is not ready\n * for input, and some stages must run to become ready (the guided Mockup\n * stop waits for its model to enter 3D).\n */\nexport function observeStage(\n el: Element,\n tell: (visible: boolean) => void,\n options: IntersectionObserverInit = { rootMargin: '200px' },\n) {\n const owner = roomOwner(el);\n let intersecting = false;\n let previous: boolean | undefined;\n const update = () => {\n const visible =\n !covered(owner ?? el) &&\n (owner ? owner.dataset.widgetActive === 'true' : intersecting);\n if (visible !== previous) {\n previous = visible;\n tell(visible);\n }\n };\n const io = new IntersectionObserver(\n (entries) => {\n intersecting = entries.some((entry) => entry.isIntersecting);\n update();\n },\n { root: scrollRoot(el), ...options },\n );\n const changes = new MutationObserver(update);\n if (owner) {\n changes.observe(owner, {\n attributes: true,\n attributeFilter: ['data-widget-active'],\n });\n }\n const unwatchCover = watchCover(el, update);\n let connected = true;\n io.observe(el);\n // Visibility may update tracked demo state; run after Glimmer's render pass.\n queueMicrotask(() => {\n if (connected) {\n update();\n }\n });\n return {\n disconnect() {\n connected = false;\n io.disconnect();\n changes.disconnect();\n unwatchCover();\n },\n };\n}\n\nexport const onStage = modifier(\n (el: HTMLElement, [tell]: [(visible: boolean) => void]) => {\n const observer = observeStage(el, tell);\n return () => {\n observer.disconnect();\n tell(false);\n };\n },\n);\n\n/**\n * Stop a card's CSS animations while nobody can see it.\n *\n * `onStage` above is for demos that own a loop and can be told to stop. This is\n * for the other twenty-odd, which own no loop at all and animate purely in CSS\n * — and between them, on an idle gallery, they were running twenty-five\n * animations at once with fourteen of those coming from one stage. Nothing was\n * broken; it is simply that a page of forty-two live demos is a page of\n * forty-two things that never rest, and the crossing has to share a thread with\n * all of them.\n *\n * It toggles a class rather than touching the animations, because\n * `animation-play-state` is inherited-by-selector and pauses a whole subtree in\n * one declaration — including animations that were declared by a stage this\n * file has never heard of. Pausing is also the right verb: a paused animation\n * keeps its position, so a card scrolled away and back does not restart its\n * decoration from frame zero, which would be a worse artefact than the cost it\n * saves.\n *\n * Web Animations — Motion's own — need the second half, because\n * `animation-play-state` cannot reach them: they are script-driven and have no\n * CSS animation to pause. The ordinary gallery pauses only endless effects,\n * allowing finite transitions to settle offscreen. The 3D room instead holds\n * every effect at its current frame until that one tile receives camera focus.\n *\n * `iterations` reads as `Infinity` or, for some engines, `null` — both mean the\n * same thing here and both are checked.\n */\nconst endless = (a: Animation) => {\n const n = a.effect?.getTiming?.().iterations;\n return n === Infinity || n === null;\n};\n\n// Batch geometry/style reads for every affected tile before changing any of\n// their animation states. One subtree scan per frame, never one per style write.\nconst pendingRest = new Map<HTMLElement, (animations: Animation[]) => void>();\nlet restFrame = 0;\nfunction scheduleRest(\n el: HTMLElement,\n apply: (animations: Animation[]) => void,\n) {\n pendingRest.set(el, apply);\n if (restFrame) {\n return;\n }\n restFrame = requestAnimationFrame(() => {\n restFrame = 0;\n const reads = [...pendingRest].map(([element, fn]) => ({\n fn,\n animations: element.getAnimations({ subtree: true }),\n }));\n pendingRest.clear();\n for (const { fn, animations } of reads) {\n fn(animations);\n }\n });\n}\n\n/**\n * Something restarted animations inside `el` without rendering: the lab,\n * retiming a stage. A resting card has to hear about it, because the\n * animations it paused were cancelled and their replacements are running.\n */\nconst RESTARTED = 'choreo-gallery:animations-restarted';\n\nexport function announceRestartedAnimations(el: Element) {\n el.dispatchEvent(new CustomEvent(RESTARTED, { bubbles: true }));\n}\n\n/**\n * Resting stages look again, every so often, for animations that started\n * since they last looked.\n *\n * A subtree change, a CSS animation starting and a retime the lab announces\n * each make a resting stage look again. A Web Animation started by script is\n * not announced: every pass of a Choreo score starts its effects that way, and\n * a pass can run while its stage rests: a tile below the fold whose score\n * starts after it first looked, or a tile under a demo page whose score passes\n * again as the page opens over it. An endless effect that starts like that\n * would play on where nobody can see it. One timer serves every resting stage,\n * and runs only while one does.\n */\nconst RECHECK_MS = 1000;\nconst resting = new Set<() => void>();\nlet recheck: ReturnType<typeof setInterval> | undefined;\nfunction rest(settle: () => void, on: boolean) {\n if (on) {\n resting.add(settle);\n } else {\n resting.delete(settle);\n }\n if (resting.size && !recheck) {\n recheck = setInterval(() => {\n for (const again of resting) {\n again();\n }\n }, RECHECK_MS);\n } else if (!resting.size && recheck) {\n clearInterval(recheck);\n recheck = undefined;\n }\n}\n\nexport const restWhenOff = modifier((el: HTMLElement) => {\n const room = !!roomOwner(el);\n let active = false;\n const paused = new Set<Animation>();\n let closed = false;\n const apply = (animations: Animation[]) => {\n if (closed) {\n return;\n }\n el.classList.toggle('is-resting', !active);\n for (const animation of animations) {\n if (!room && !endless(animation)) {\n continue;\n }\n if (!active && animation.playState === 'running') {\n animation.pause();\n paused.add(animation);\n }\n }\n if (active) {\n for (const animation of paused) {\n if (animation.playState === 'paused') {\n animation.play();\n }\n }\n paused.clear();\n }\n };\n const settle = () => scheduleRest(el, apply);\n const observer = observeStage(el, (visible) => {\n active = visible;\n rest(settle, !visible);\n settle();\n });\n // Catch animations created by a pending render while this card is frozen.\n const changes = new MutationObserver(() => {\n if (!active) {\n settle();\n }\n });\n changes.observe(el, {\n childList: true,\n subtree: true,\n attributes: room,\n attributeFilter: room ? ['style'] : undefined,\n });\n el.addEventListener('animationstart', settle);\n // …and animations restarted in place, which change no DOM\n el.addEventListener(RESTARTED, settle);\n return () => {\n observer.disconnect();\n changes.disconnect();\n el.removeEventListener('animationstart', settle);\n el.removeEventListener(RESTARTED, settle);\n rest(settle, false);\n pendingRest.delete(el);\n active = true;\n apply([]);\n closed = true;\n };\n});\n\nexport default onStage;\n";
190
190
  //#endregion
191
191
  //#region ../choreo-gallery/realm/lib/pointer.ts?raw
192
192
  var pointer_default$1 = "import { modifier } from 'ember-modifier';\n\nexport function preventSelect(event: Event) {\n event.preventDefault();\n}\n\n/**\n * Keeps clicks inside the element. The host opens an embedded card on a click\n * anywhere inside it, so a live demo embedded in a document marks its own\n * controls with this, and their clicks stay with the demo.\n */\nexport const ownClicks = modifier((element: HTMLElement) => {\n let stop = (event: Event) => event.stopPropagation();\n element.addEventListener('click', stop);\n return () => element.removeEventListener('click', stop);\n});\n\n/**\n * What a press inside a live stage can land on that is the stage's own: a\n * control, or a surface the stage marks `data-stage-press` because a plain\n * tap or hold on it does something (advancing a slide, steering a car).\n */\nconst CONTROL =\n 'a[href], button, input, select, textarea, label, summary, [contenteditable], [role=\"button\"], [role=\"slider\"], [role=\"switch\"], [role=\"checkbox\"], [role=\"tab\"], [tabindex], [data-stage-press]';\n\n/** how far a press travels before it is a drag rather than a click */\nconst DRAG_PX = 4;\n\n/**\n * Keeps the clicks a live stage uses for itself, and lets every other click\n * through. The host opens a fitted card on a click anywhere inside it, so a\n * tile whose stage is mostly picture stays a link: a click on the picture\n * opens the demo, while a press on one of the stage's controls, or one that\n * ends a drag, stays with the stage.\n */\nexport const ownControlClicks = modifier((element: HTMLElement) => {\n let from: { x: number; y: number } | null = null;\n let down = (event: PointerEvent) => {\n from = { x: event.clientX, y: event.clientY };\n };\n let click = (event: MouseEvent) => {\n let target = event.target instanceof Element ? event.target : null;\n let control = target?.closest(CONTROL);\n let dragged =\n from !== null &&\n Math.hypot(event.clientX - from.x, event.clientY - from.y) > DRAG_PX;\n from = null;\n if ((control && element.contains(control)) || dragged) {\n event.stopPropagation();\n }\n };\n // the press is read on the way down, so a stage that keeps its own\n // pointerdown from bubbling still leaves the drag check its start\n element.addEventListener('pointerdown', down, { capture: true });\n element.addEventListener('click', click);\n return () => {\n element.removeEventListener('pointerdown', down, { capture: true });\n element.removeEventListener('click', click);\n };\n});\n\n/**\n * The click handler for a link to the card it sits in, where the host opens\n * the card. A plain click is the host's: the link's own navigation is\n * stopped and the click goes on to the host (its overlay in interact mode,\n * the gallery's grid on a published site). A modified click (new tab, new\n * window) is the browser's, and the host never hears of it.\n */\nexport function openThroughHost(event: Event): void {\n if (\n event instanceof MouseEvent &&\n (event.button !== 0 ||\n event.metaKey ||\n event.ctrlKey ||\n event.shiftKey ||\n event.altKey)\n ) {\n event.stopPropagation();\n return;\n }\n event.preventDefault();\n}\n";
@@ -246,7 +246,7 @@ var widget_tour_default = "/** Editorial interest scores: visual payoff, distinc
246
246
  var word_layout_default = "/**\n * Where each word sits, worked out as arithmetic.\n *\n * The In-place demo's whole difficulty was that `font-size` is a LAYOUT\n * property. Every arrangement that let the browser flow the words collided\n * with it: two copies of a word sliding past each other while the arriving\n * one relaid itself out; a line whose inter-word gaps went wrong mid-flight\n * because each word was flying on its own and nothing was interpolating the\n * LINE. The gap between \"14\" and \"March\" is not a property of either word.\n *\n * pretext dissolves that. It measures text with the browser's own font\n * engine through canvas and then does layout as pure arithmetic — no DOM, no\n * reflow, no need for the text to be on screen at the size you are asking\n * about. So both poses of a line can be computed up front, at both scales,\n * and the words can be taken out of flow entirely: absolutely positioned,\n * each one's x a number the score tweens. Nothing is in layout, so nothing\n * can be disturbed by the type changing size.\n *\n * That also answers the harder question this demo stands for. A record's\n * reading view and its editor are not usually written by the same person —\n * they are separate components, and neither one can measure the other's\n * layout, because only one of them is rendered at a time. Measuring the pose\n * you are NOT showing is exactly what pretext is for.\n */\nimport { measureNaturalWidth, prepareWithSegments } from '@chenglou/pretext';\n\n/** one pose of the type: what the canvas font engine needs to be asked */\nexport interface TypeScale {\n /** the family, as a CSS font shorthand tail — `'Archivo'` */\n family: string;\n /** px */\n size: number;\n /**\n * A `font-stretch` keyword, as the canvas font shorthand spells it.\n *\n * This is how a variable font's `wdth` axis can be BOTH measured and\n * rendered. `font-variation-settings` cannot appear in a font shorthand,\n * so a design that reaches the axis that way is a design pretext measures\n * a face wider than the one on screen — half a line of error on a long\n * name. `font-stretch` is in the shorthand, canvas honours it, and CSS\n * maps it onto the same axis.\n */\n stretch?: string;\n /** em, as CSS letter-spacing is written; converted to px for the measure */\n tracking: number;\n weight: number;\n}\n\nexport interface WordBox {\n width: number;\n /** left edge, relative to the start of the line */\n x: number;\n}\n\n/** the canvas `font` shorthand for a scale */\nconst shorthand = (t: TypeScale) =>\n `${t.weight} ${t.stretch ? `${t.stretch} ` : ''}${t.size}px ${t.family}`;\n\n/**\n * The words of one line, each with its left edge and width at this scale.\n *\n * `start` is the line's own inset — the form pads its fields and the reading\n * view does not, and folding that in here means the inset is tweened with\n * everything else rather than jumping at the frame the mode changes.\n */\n/**\n * What the canvas says, over what the page actually draws.\n *\n * pretext measures with the canvas font shorthand, and an engine is entitled\n * to parse that shorthand differently from the way it renders CSS. Blink\n * honours `font-stretch` in the shorthand — measured, 311px becomes 280px\n * for the same string — and WebKit has historically ignored it, which means\n * the plan is computed against the WIDE cut while the screen draws the\n * narrow one. The first word lands and every word after it drifts, which is\n * exactly the \"the spacing is off, but only in Safari\" report.\n *\n * Rather than guess which engine honours what, the two are reconciled: one\n * sample string measured both ways, once per face, and every width scaled by\n * the ratio between them. On an engine where they already agree the ratio is\n * 1 and nothing changes. Tracking is left out of the calibration on purpose\n * — it is a per-character constant, not a property of the face, and CSS adds\n * it after the last glyph where canvas may not.\n */\nconst SAMPLE = 'Marguerite Villanueva 1986 @kiln.studio';\nconst ratios = new Map<string, number>();\n\nlet probe: HTMLElement | undefined;\n\nfunction ratioFor(scale: TypeScale): number {\n const key = shorthand(scale);\n const known = ratios.get(key);\n if (known !== undefined) {\n return known;\n }\n if (typeof document === 'undefined') {\n return 1;\n }\n if (!probe) {\n probe = document.createElement('span');\n probe.setAttribute('aria-hidden', 'true');\n probe.style.cssText =\n 'position:absolute;left:-9999px;top:0;white-space:pre;visibility:hidden;';\n document.body.appendChild(probe);\n }\n probe.style.fontFamily = `'${scale.family}', sans-serif`;\n probe.style.fontSize = `${scale.size}px`;\n probe.style.fontWeight = String(scale.weight);\n probe.style.fontStretch = scale.stretch ?? 'normal';\n probe.style.letterSpacing = 'normal';\n probe.textContent = SAMPLE;\n const drawn = probe.getBoundingClientRect().width;\n const measured = measureNaturalWidth(\n prepareWithSegments(SAMPLE, key, { letterSpacing: 0 }),\n );\n const ratio = drawn > 0 && measured > 0 ? drawn / measured : 1;\n ratios.set(key, ratio);\n return ratio;\n}\n\n/** one string's natural width at a scale — no wrapping, no DOM */\nexport function measureText(text: string, scale: TypeScale): number {\n return (\n measureNaturalWidth(\n prepareWithSegments(text, shorthand(scale), {\n letterSpacing: scale.tracking * scale.size,\n }),\n ) * ratioFor(scale)\n );\n}\n\nexport function layoutWords(\n words: string[],\n scale: TypeScale,\n start = 0,\n): WordBox[] {\n const font = shorthand(scale);\n const letterSpacing = scale.tracking * scale.size;\n const ratio = ratioFor(scale);\n const measure = (text: string) =>\n text\n ? measureNaturalWidth(\n prepareWithSegments(text, font, { letterSpacing }),\n ) * ratio\n : 0;\n\n // Each word's left edge is the WHOLE line's width less the width of the\n // line from that word on. Nothing here invents an inter-word gap, and that\n // is the point: the first version added a flat 0.3em between words, which\n // put the first word on the pixel and every word after it a few out —\n // visible as the space between a first and last name changing on handover.\n // A space's advance is the font's business, it varies with the face and\n // with letter-spacing, and it is already inside these measurements.\n const line = measure(words.join(' '));\n const out: WordBox[] = [];\n for (let index = 0; index < words.length; index++) {\n const suffix = measure(words.slice(index).join(' '));\n out.push({ width: measure(words[index]!), x: start + line - suffix });\n }\n return out;\n}\n\n/**\n * Resolve the fonts a scale needs before anything is measured.\n *\n * Canvas measurement is only as good as the face the browser has: ask before\n * Archivo has loaded and every width comes back in the fallback's metrics,\n * which is a whole line's worth of error that never corrects itself. The\n * demo measures once this resolves, and again if a face arrives later.\n */\nexport async function typeReady(scales: TypeScale[]): Promise<void> {\n const fonts = (document as Document & { fonts?: FontFaceSet }).fonts;\n if (!fonts) {\n return;\n }\n await Promise.all(scales.map((scale) => fonts.load(shorthand(scale))));\n await fonts.ready;\n // warm the canvas-against-page calibration while nothing is in flight: it\n // reads the DOM once per face, and that is not a thing to do mid-pass\n for (const scale of scales) {\n ratioFor(scale);\n }\n}\n";
247
247
  //#endregion
248
248
  //#region ../choreo-gallery/realm/lib/xstress-live.ts?raw
249
- var xstress_live_default = "/**\n * Live innards for Crossing's plate. They are not Choreo participants: they\n * ride inside the kept hero and keep running while it flies. CSS loops on the\n * hero itself freeze during a crossing (they would fight the flight\n * transform); a <video> and an overflow pane do not.\n */\nimport { modifier } from 'ember-modifier';\n\nfunction reduced() {\n return window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\n/**\n * Muted autoplay is allowed; the clip is still kicked in case the browser\n * waited. Reduced motion holds it on its first frame.\n */\nexport const playClip = modifier((video: HTMLVideoElement) => {\n video.muted = true;\n video.loop = true;\n video.playsInline = true;\n if (reduced()) {\n video.pause();\n return;\n }\n const kick = () => {\n video.play().catch(() => {\n /* autoplay can still refuse; the element is the stress, not the decode */\n });\n };\n kick();\n video.addEventListener('canplay', kick);\n return () => video.removeEventListener('canplay', kick);\n});\n\n/**\n * Auto-scrolls a real overflow pane (scrollTop, not a translate loop) so a\n * cut can land mid-scroll. Reduced motion leaves it still.\n */\nexport const liveScroll = modifier((el: HTMLElement) => {\n if (reduced()) {\n return;\n }\n let raf = 0;\n let alive = true;\n const step = () => {\n if (!alive) {\n return;\n }\n const max = el.scrollHeight - el.clientHeight;\n if (max > 1) {\n const next = el.scrollTop + 0.45;\n el.scrollTop = next >= max - 0.5 ? 0 : next;\n }\n raf = requestAnimationFrame(step);\n };\n raf = requestAnimationFrame(step);\n return () => {\n alive = false;\n cancelAnimationFrame(raf);\n };\n});\n";
249
+ var xstress_live_default = "/**\n * Live innards for Crossing's plate. They are not Choreo participants: they\n * ride inside the kept hero and keep running while it flies. CSS loops on the\n * hero itself freeze during a crossing (they would fight the flight\n * transform); a <video> and an overflow pane do not.\n *\n * They run while the deck is on stage, though, and rest where they are when\n * it is scrolled away or covered. Whether it is on stage is asked of the deck\n * rather than of the hero: the hero is what flies, and where its box is\n * mid-flight says nothing about whether the deck can be seen.\n */\nimport { modifier } from 'ember-modifier';\n\nimport { observeStage } from './onstage';\n\nfunction reduced() {\n return window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n}\n\nfunction deckOf(el: Element): Element {\n return el.closest('.xstress-viewport') ?? el;\n}\n\n/**\n * Muted autoplay is allowed; the clip is still kicked in case the browser\n * waited. Reduced motion holds it on its first frame, and so does a deck\n * nobody can see — `pause()` also cancels the element's own `autoplay`.\n */\nexport const playClip = modifier((video: HTMLVideoElement) => {\n video.muted = true;\n video.loop = true;\n video.playsInline = true;\n if (reduced()) {\n video.pause();\n return;\n }\n let onStage = false;\n const kick = () => {\n if (!onStage) {\n return;\n }\n video.play().catch(() => {\n /* autoplay can still refuse; the element is the stress, not the decode */\n });\n };\n video.addEventListener('canplay', kick);\n const stage = observeStage(deckOf(video), (visible) => {\n onStage = visible;\n if (visible) {\n kick();\n } else {\n video.pause();\n }\n });\n return () => {\n stage.disconnect();\n video.removeEventListener('canplay', kick);\n };\n});\n\n/**\n * Auto-scrolls a real overflow pane (scrollTop, not a translate loop) so a\n * cut can land mid-scroll. Reduced motion leaves it still.\n */\nexport const liveScroll = modifier((el: HTMLElement) => {\n if (reduced()) {\n return;\n }\n let raf = 0;\n const step = () => {\n const max = el.scrollHeight - el.clientHeight;\n if (max > 1) {\n const next = el.scrollTop + 0.45;\n el.scrollTop = next >= max - 0.5 ? 0 : next;\n }\n raf = requestAnimationFrame(step);\n };\n const stage = observeStage(deckOf(el), (visible) => {\n cancelAnimationFrame(raf);\n raf = visible ? requestAnimationFrame(step) : 0;\n });\n return () => {\n stage.disconnect();\n cancelAnimationFrame(raf);\n };\n});\n";
250
250
  //#endregion
251
251
  //#region ../choreo-gallery/realm/notes/build-order.gts?raw
252
252
  var build_order_default$1 = "import type { TOC } from '@ember/component/template-only';\n\nimport { CodeBox } from '../shell/code-box';\n\nconst SCORE = `<c.Sequence>\n <c.Tween @name='b1' @of={{c.id 'plate'}} … />\n <c.Tween @name='b2' @at={{at 'b1'}} @delay={{0.14}} … />\n <c.Tween @name='b3' @at={{after 'b2'}} … />\n</c.Sequence>`;\n\nconst EFFECT = `{{! Pop: a scale through backOut, opacity leading early }}\n<c.Tween\n @opacity={{array 0 1 1 1}}\n @scale={{array 0 1}}\n @ease='backOut'\n @duration={{secondsOf build.ms}}\n/>`;\n\nconst DELIVERY = `<c.Tween\n @of={{c.id 'word'}}\n @by='character'\n @opacity={{array 0 1 1}} @scale={{array 0.78 1}} @y={{array 18 0}}\n @duration={{0.76}}\n/>`;\n\nconst TRANSPORT = `scrub = (event) => {\n this.held = true; // a hand has claimed the clock\n run.pause();\n run.time = Number(event.target.value);\n};`;\n\nconst ADOPT = `// any pass replaces the run; the new one resumes where the old stood\nconst target = this.reseek ?? this.t;\nif (target > 0 && target < run.duration) {\n run.time = target;\n}`;\n\nconst READER = `// NOT tracked, and that is load-bearing: the region's render\n// detector re-runs on EVERY render, so a tracked value written at\n// 60fps would replay the pass at 60fps — cancelling the run it is\n// trying to watch. The clock and the range are written by hand.\nprivate t = 0;\nprivate paint() {\n this.clockEl.textContent = \\`\\${this.t.toFixed(2)} …\\`;\n this.rangeEl.value = String(this.t);\n}`;\n\nconst COSTUME = `// deliver.ts — the split is a costume, and a costume must not\n// change the body's shape: whitespace the container was COLLAPSING\n// (a template's newline and indentation are real text nodes) would\n// take width as \\`pre\\` spans, and a newline would become a hard break.\nconst ws = getComputedStyle(el).whiteSpace;\nconst collapsing = ws === 'normal' || ws === 'nowrap' || ws === 'pre-line';`;\n\n/**\n * Deep dive for the Build Order demo.\n */\nconst BuildOrderNotes: TOC<object> = <template>\n <section class='dive' aria-label='How it works'>\n <header class='dive-head'>\n <p class='dive-kicker'>How it works</p>\n <h2>A score, not a timeline</h2>\n <p class='dive-lede'>\n Nobody sketching a logo animation writes “the ring finishes at 880ms.”\n They write\n <em>the ring draws</em>,\n <em>the mark pops with it</em>,\n <em>the wordmark comes after</em>. This stage takes that sentence\n literally — the score is written in the template, every start time you\n see is resolved by the compiler from two words and a number, and there\n is nowhere in the demo that a time is typed by hand.\n </p>\n </header>\n\n <section class='dd'>\n <p class='dd-fn'>@name · at() · after()<span>the score</span></p>\n <h3>With and after are anchors</h3>\n <div class='dd-col'>\n <p>\n A build names a part, an effect, a duration — and a relation to the\n build above it.\n <b>With</b>\n is\n <code>@at=\\{{at 'b3'}}</code>\n — start where build 3 started;\n <b>after</b>\n is\n <code>@at=\\{{after 'b3'}}</code>\n — start where it ended. A delay, in either case, is added on top.\n Build 1 is the exception: with nothing above it, its Start is\n Keynote’s own “On Click” — the sequence’s natural flow, the run’s own\n zero.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{SCORE}} />\n <div class='dd-col'>\n <p>\n Because the relation always points at the\n <em>previous</em>\n build, moving one build moves everything under it. Switch build 6 from\n After to With in the inspector and every start time downstream\n recomputes — nothing had to be re-typed, because nothing was ever a\n timecode to begin with. An anchored step is lifted out of the\n sequence’s flow, and its end still counts toward the run’s length: the\n compiler’s runtime is a\n <code>max</code>, not a last.\n </p>\n </div>\n\n <figure class='dd-fig'>\n <svg\n class='dg'\n viewBox='0 0 900 260'\n role='img'\n aria-label=\"Three lanes on a timeline. Build 2 hangs from build 1's\n start, marked with. Build 3 hangs from build 2's end, marked\n after, offset by a delay.\"\n >\n <text class='dg-eb' x='20' y='24'>THE RESOLVED TIMELINE</text>\n\n <line class='dg-rule' x1='20' y1='230' x2='880' y2='230' />\n\n <rect class='dg-box' x='20' y='50' width='220' height='30' rx='6' />\n <text class='dg-t' x='34' y='70'>1 · Plate — Move In</text>\n\n <rect class='dg-hot' x='20' y='100' width='330' height='30' rx='6' />\n <text class='dg-t is-hot' x='34' y='120'>2 · Tail — Line Draw</text>\n <text class='dg-t is-faint' x='20' y='94'>with build 1</text>\n\n <rect class='dg-box' x='350' y='150' width='280' height='30' rx='6' />\n <text class='dg-t' x='364' y='170'>3 · Head — Line Draw</text>\n <text class='dg-t is-faint' x='350' y='144'>after build 2, +0ms</text>\n\n <g class='dg-hair dg-dash'>\n <line x1='20' y1='50' x2='20' y2='230' />\n <line x1='350' y1='100' x2='350' y2='230' />\n </g>\n <circle class='dg-dot' cx='20' cy='230' r='4.5' />\n <circle class='dg-dot' cx='350' cy='230' r='4.5' />\n\n <text class='dg-t is-faint' x='20' y='250' text-anchor='middle'>0 ms</text>\n <text class='dg-t is-faint' x='350' y='250' text-anchor='middle'>build\n 2 ends</text>\n\n <text class='dg-t is-dim' x='360' y='200'>with hangs from a START ·\n after hangs from an END</text>\n </svg>\n <figcaption>\n The faint vertical lines in the demo’s own transport are drawn from\n exactly this: a tick at the moment each build was timed against. The\n bars themselves are read back from\n <code>run.cues</code>\n — the compiler’s resolved answer, never a second copy.\n </figcaption>\n </figure>\n </section>\n\n <section class='dd'>\n <p class='dd-fn'>keyframe values<span>the effects</span></p>\n <h3>An effect is keyframes and an easing</h3>\n <div class='dd-col'>\n <p>\n There is no effects engine. Pop is a scale through\n <code>backOut</code>; Line Draw is\n <code>@pathLength</code>\n from 0 to 1; Wipe is two\n <code>inset()</code>\n clip frames. The leading opacity frames are Keynote’s “builds in” made\n of arithmetic: a keyframe array’s\n <em>first</em>\n value is pinned onto the part from the run’s very start, so a build\n that begins at 2.1s sits hidden — at its own first frame — until its\n window opens.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{EFFECT}} />\n <div class='dd-col'>\n <p>\n That pin is also why every effect here states both ends of its\n journey. An effect that only named its destination would inherit\n whatever the part was doing before — and a build order is a statement\n about the whole journey, not a nudge toward a target.\n </p>\n </div>\n </section>\n\n <section class='dd'>\n <p class='dd-fn'>@by='character'<span>delivery</span></p>\n <h3>Delivery is a second timeline, inside the first</h3>\n <div class='dd-col'>\n <p>\n Set a text build’s Delivery to By Character and the wordmark does not\n simply fade in as one block — every glyph gets its own turn, and the\n library does the splitting: the sprite’s own text nodes are lifted out\n whole, stand-in spans deliver the animation, and the restore puts\n Glimmer’s own nodes back exactly where they were. The build still owns\n its stated window; each cell gets 55% of it, starts spread so the\n <em>last</em>\n cell finishes exactly on the build’s own end.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{DELIVERY}} />\n <div class='dd-col'>\n <p>\n The split taught this page a lesson worth keeping: a costume must not\n change the body’s shape. A template’s newline and indentation are real\n text nodes, and stand-in spans wear\n <code>white-space: pre</code>\n — so whitespace the container had been collapsing suddenly took width,\n and the whole line jumped aside for its own delivery. The split now\n collapses the way the element’s computed style does.\n </p>\n </div>\n <CodeBox @label='glimmer-motion' @source={{COSTUME}} />\n </section>\n\n <section class='dd'>\n <p class='dd-fn'>c.run<span>the transport</span></p>\n <h3>The transport holds the run</h3>\n <div class='dd-col'>\n <p>\n Play, pause and the scrubber are not three code paths — they are\n <code>run.play()</code>,\n <code>run.pause()</code>\n and\n <code>run.time = t</code>\n on the library’s own clock.\n <code>time</code>\n is settable in either direction, and a scrubbed frame is a\n <em>computed still</em>: the run retires its animations and stands\n every value exactly where the score says t looks like, no memory of\n the frame before.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{TRANSPORT}} />\n <div class='dd-modes'>\n <div class='dd-mode'>\n <span class='dd-tag'>playing</span>\n <b>The run drives</b>\n <p>Its master clock crosses cues and the platform plays them — plain\n tweens accelerate onto WAAPI, off the main thread.</p>\n </div>\n <div class='dd-mode'>\n <span class='dd-tag'>scrubbing</span>\n <b>A hand drives</b>\n <p>Dragging the range pauses the run and marks it\n <em>held</em>, so scrolling the card away and back cannot quietly\n restart what a hand stopped.</p>\n </div>\n <div class='dd-mode'>\n <span class='dd-tag'>off-screen</span>\n <b>Nobody drives</b>\n <p>An IntersectionObserver pauses playback below 35% visible and\n resumes it on return — unless a hand paused it first.</p>\n </div>\n </div>\n </section>\n\n <section class='dd'>\n <p class='dd-fn'>edit()<span>build-order.gts</span></p>\n <h3>The score is editable while it runs</h3>\n <div class='dd-col'>\n <p>\n The inspector does not stage changes for the next play — it replaces\n the build, the steps re-render, and the region replays its pass\n against the new schedule. The transport then adopts the new run and\n puts it back at the\n <em>same</em>\n t: retiming build 2 while parked at 1.4s shows you what 1.4s now looks\n like, immediately.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{ADOPT}} />\n <div class='dd-col'>\n <p>\n Continuity is the rule, not the exception. Any pass replaces the run —\n an edit, the loop’s next take, an unrelated render — and the new run\n resumes where the old one stood. Only a run that had actually finished\n starts its successor from zero, which is the loop coming round.\n </p>\n </div>\n </section>\n\n <section class='dd'>\n <p class='dd-fn'>the reader<span>build-order.gts</span></p>\n <h3>A transport is a reader, not a clock</h3>\n <div class='dd-col'>\n <p>\n The one real discipline in this file: the playhead readouts are\n <em>not</em>\n tracked state. A region re-snapshots on every render — that is what\n makes it a region — so a tracked value written sixty times a second\n would replay the pass sixty times a second, each pass cancelling the\n run the transport was trying to watch. The first draft of this page\n did exactly that, and the symptom was spectacular: a timeline whose\n clock crawled at a fiftieth of real time while the canvas stayed\n empty, every run dying in infancy.\n </p>\n </div>\n <CodeBox @label='build-order.gts' @source={{READER}} />\n <div class='dd-col'>\n <p>\n So the clock text, the range’s value and the playhead’s position are\n written imperatively on the transport’s own frame, and the only\n tracked writes left are the rare ones — an edit, a loop take, a press\n of play. It is the same lesson every demo with a per-frame value has\n to learn once: Glimmer’s render loop and an animation’s frame loop are\n different clocks, and state that belongs to the second must not be\n phrased in the first.\n </p>\n </div>\n </section>\n </section>\n</template>;\n\nexport default BuildOrderNotes;\nexport { BuildOrderNotes };\n";
@@ -339,7 +339,7 @@ var boxel_glyph_default = "import Component from '@glimmer/component';\n\n/** SV
339
339
  var choreo_mark_default = "/**\n * The mark: a comet lettering a C.\n *\n * A choreography is not a picture of movement — it is the same element,\n * measured before and after, and the line the engine draws between those\n * measurements. So the mark is that line, arriving, twice over: an outer\n * arc that fades in from the past, sweeps 284°, and terminates inside the\n * filled bead that is the element NOW — and a second, inner orbit on its\n * own timeline, arriving at its own dot. One arc is motion; two in phase\n * are a choreography. The gap they leave open letters Choreo's C.\n *\n * The geometry is exported because the mark exists twice: static in the\n * top bar, and taken apart in the Build Order demo, where its own assembly\n * is the demonstration. One set of numbers, so the logo being animated is\n * provably the logo being worn.\n */\n\n/** the fading half of the outer sweep: bottom tip round the left, into the past */\nexport const TAIL = 'M18.3 16.9A8 8 0 0 1 4 12';\n\n/** the arriving half: over the top, ending where the bead sits */\nexport const HEAD = 'M4 12A8 8 0 0 1 18.3 7.1';\n\n/** the outer element now — the arc terminates inside it */\nexport const BEAD = { cx: 18.3, cy: 7.1, r: 2.8 } as const;\n\n/**\n * The second timeline: 210° from the lower left over the top, arriving at\n * its own dot. It starts 5° further round than the geometry alone wants, so\n * its lighter stroke reads as ending level with the heavier outer sweep.\n */\nexport const ORBIT = 'M13.66 16.07A4.4 4.4 0 0 1 8.4 9.5';\n\n/**\n * The inner element now — the last 85° of the sweep in the hot color. It\n * trails the outer bead by 22°, which keeps the two clear of each other and\n * makes the mark read as a chase rather than a diagram.\n */\nexport const TIP = 'M8.4 9.5A4.4 4.4 0 0 1 14.2 8.2';\n\nexport const ChoreoMark = <template>\n {{! Static on purpose — the mark performs in the Build Order demo; up here\n it has already arrived. }}\n <svg class='choreo-mark' viewBox='0 0 24 24' aria-hidden='true'>\n <path\n d={{TAIL}}\n fill='none'\n stroke='var(--mark-tail)'\n stroke-width='2.2'\n stroke-linecap='round'\n />\n <path\n d={{HEAD}}\n fill='none'\n stroke='var(--ember-hot)'\n stroke-width='2.2'\n stroke-linecap='round'\n />\n <circle cx={{BEAD.cx}} cy={{BEAD.cy}} r={{BEAD.r}} fill='var(--ember)' />\n <path\n d={{ORBIT}}\n fill='none'\n stroke='var(--mark-orbit)'\n stroke-width='1.6'\n stroke-linecap='round'\n />\n <path\n d={{TIP}}\n fill='none'\n stroke='var(--ember-hot)'\n stroke-width='1.6'\n stroke-linecap='round'\n />\n </svg>\n <style scoped>\n .choreo-mark {\n width: 34px;\n height: 34px;\n display: block;\n }\n </style>\n</template>;\n\nexport default ChoreoMark;\n";
340
340
  //#endregion
341
341
  //#region ../choreo-gallery/realm/shell/choreo-root.gts?raw
342
- var choreo_root_default = "import type { TOC } from '@ember/component/template-only';\nimport { MotionConfig } from 'glimmer-motion';\n\nimport { theme } from '../lib/theme';\n\ninterface Signature {\n Blocks: { default: [] };\n Element: HTMLDivElement;\n}\n\n/**\n * The boundary every piece of the gallery renders inside: the palette, the\n * type, and the few element defaults the demos were designed against.\n *\n * The tokens are custom properties declared on this element, so everything\n * inside it — the shell's own components and every demo stage — inherits them\n * without the gallery ever styling the host's document. The theme switch is\n * this element's `data-theme`.\n */\nexport const ChoreoRoot: TOC<Signature> = <template>\n <div\n class='choreo-site'\n data-choreo-site\n data-test-choreo-site\n data-theme={{theme.resolved}}\n ...attributes\n >\n <MotionConfig @reducedMotion='user'>\n {{yield}}\n </MotionConfig>\n </div>\n <style scoped>\n .choreo-site {\n color-scheme: dark;\n /* Dark surfaces are a warm charcoal ladder, not near-black.\n well < bg < page < elev < spot, so recessed chrome still sinks and\n cards still lift, without a black floor. */\n --bg-well: #221e1a;\n --bg: #2a2521;\n --bg-elev: #3a342e;\n --bg-spot: #443c35;\n /* Playhead's own surfaces, a shade above the shared ladder so its\n small UI sits ON the page rather than cutting a hole in it. */\n --ph-card-top: #3d362f;\n --ph-card-bot: #322c27;\n --ph-go-top: #4a4139;\n --ph-go-bot: #2c2722;\n --ph-receipt-top: #403830;\n --ph-receipt-bot: #2e2924;\n --ph-row: rgba(0, 0, 0, 0.16);\n --ph-groove: rgba(0, 0, 0, 0.22);\n --ph-btn: rgba(0, 0, 0, 0.26);\n /* the page's own resting tone. --bg is the recessed canvas; cards use\n --bg-elev, a notch above this, so they lift instead of sinking. */\n --bg-page: #2e2924;\n --ink: #f3ece3;\n /* a step between --ink and --ink-dim, for the notes' headings */\n --ink-soft: #e6dfd6;\n --ink-dim: #d2c9bf;\n --ink-faint: #b8aea3;\n --line: rgba(var(--ink-rgb), 0.09);\n --line-strong: rgba(var(--ink-rgb), 0.16);\n /* bare triplets for rgba(var(--x-rgb), a) call sites that want an\n alpha no token covers; redefined per theme so they repaint */\n --bg-rgb: 42, 37, 33;\n --ink-rgb: 243, 236, 227;\n /* Shadows were picked against a dark page. Light mode scales the\n alpha and warms the tint; in dark the multiplier is 1. */\n --shadow-rgb: 0, 0, 0;\n --shadow-a: 1;\n /* a raised panel goes lighter than the page in dark mode and darker\n in light mode; this is that one bit, so panels flip together */\n --surface-tint-rgb: 255, 255, 255;\n --ember: #ff3b1f;\n --ember-hot: #ff6a3a;\n --copper: #e4a35a;\n --iris: #9667f7;\n --steel: #8b969c;\n --teal: #4fb8a6;\n --azure: #6ea8f0;\n /* An accent as TEXT. The accents above are tuned as fills and rules;\n small type in them needs 4.5:1 on --bg, --bg-page, --bg-elev and the\n faint tints laid over them (a code box, a tile's meta, an API pill), so\n each one an element sets text in has an -ink twin, lifted here and\n darkened in light mode. Text reads the -ink token; fills,\n rules and glows keep the accent. */\n --ember-ink: #ff7a4e;\n --copper-ink: var(--copper);\n --iris-ink: #af8bf9;\n --steel-ink: #96a0a5;\n /* the small uppercase label over a section, a tile or a code sample */\n --eyebrow: var(--ember-ink);\n /* ember text on an ember-tinted fill, such as a diagram's hot box */\n --hot-ink: #ff9370;\n /* a focus indicator needs 3:1 against whatever it is drawn over */\n --focus-ring: var(--ember-hot);\n --glow: rgba(255, 59, 31, 0.38);\n /* the mark's fading tail and its inner orbit. The -dark pair holds in\n either theme, for a mark that sits on a dark ground. */\n --mark-tail-dark: rgba(255, 59, 31, 0.35);\n --mark-orbit-dark: rgba(139, 150, 156, 0.75);\n --mark-tail: var(--mark-tail-dark);\n --mark-orbit: var(--mark-orbit-dark);\n --syn-string: #ffb36a;\n --gradient-tail: var(--ink);\n --font-display: 'Syne', 'Avenir Next', sans-serif;\n --font-display-alt: 'Archivo', 'Avenir Next Condensed', sans-serif;\n --font: 'IBM Plex Sans', 'Helvetica Neue', sans-serif;\n --font-mono: 'IBM Plex Mono', ui-monospace, monospace;\n --ease: cubic-bezier(0.22, 1, 0.36, 1);\n --page: 1180px;\n /* one flat color plus two soft radial glows — no texture, so the\n ground is as composable as any other layer */\n --wash:\n radial-gradient(\n 1200px 640px at 12% -10%,\n rgba(255, 59, 31, 0.16),\n transparent 55%\n ),\n radial-gradient(\n 900px 480px at 110% 8%,\n rgba(228, 163, 90, 0.08),\n transparent 50%\n ),\n var(--bg-page);\n\n position: relative;\n min-height: 100%;\n color: var(--ink);\n font-family: var(--font);\n font-size: 16px;\n line-height: 1.5;\n background: var(--wash);\n transition:\n background 220ms var(--ease),\n color 220ms var(--ease);\n }\n\n /* Light mode. The accents carry over unchanged as fills and rules; every\n ink that sets text gets a darker value that holds 4.5:1 on cream. */\n .choreo-site[data-theme='light'] {\n color-scheme: light;\n --bg-well: #e2dccf;\n --bg: #efe9df;\n --bg-elev: #f7f3ec;\n --bg-spot: #fbf8f3;\n --shadow-rgb: 72, 56, 44;\n --shadow-a: 0.3;\n --ph-card-top: var(--bg-spot);\n --ph-card-bot: var(--bg-well);\n --ph-go-top: var(--bg-spot);\n --ph-go-bot: var(--bg-well);\n --ph-receipt-top: var(--bg-spot);\n --ph-receipt-bot: var(--bg-well);\n --ph-row: var(--bg-well);\n --ph-groove: var(--bg-well);\n --ph-btn: var(--bg-well);\n --bg-page: #f2ece1;\n --ink: #211d18;\n --ink-soft: #47423a;\n --ink-dim: #5e564b;\n --ink-faint: #6d6458;\n --line: rgba(33, 29, 24, 0.1);\n --line-strong: rgba(33, 29, 24, 0.18);\n --bg-rgb: 239, 233, 223;\n --ink-rgb: 33, 29, 24;\n --surface-tint-rgb: 33, 29, 24;\n --ember-ink: #bf2e00;\n --copper-ink: #905824;\n --iris-ink: #7334f5;\n --steel-ink: #5e686e;\n --hot-ink: #a02600;\n --focus-ring: var(--ember-ink);\n --mark-tail: rgba(255, 59, 31, 0.5);\n --mark-orbit: rgba(94, 104, 110, 0.85);\n --syn-string: #a24f00;\n --gradient-tail: var(--copper);\n --lightbox-ring: rgba(33, 29, 24, 0.12);\n }\n\n /* The element defaults the demos were designed against. :where() gives\n them no more weight than a bare element selector, so any class rule a\n component sets on its own elements outranks them. */\n .choreo-site,\n :where(.choreo-site) :deep(:where(*)),\n :where(.choreo-site) :deep(:where(*)::before),\n :where(.choreo-site) :deep(:where(*)::after) {\n box-sizing: border-box;\n }\n\n :where(.choreo-site) :deep(:where(a)) {\n color: inherit;\n text-decoration: none;\n }\n\n :where(.choreo-site) :deep(:where(button, input)) {\n font: inherit;\n color: inherit;\n }\n\n :where(.choreo-site) :deep(:where(button)) {\n cursor: pointer;\n }\n\n /* No double-tap-to-zoom on anything you are meant to tap: Safari's\n wait for a second tap reads as lag on demos you tap repeatedly. */\n :where(.choreo-site)\n :deep(:where(button, a, select, summary, [role='button'])) {\n touch-action: manipulation;\n }\n\n :where(.choreo-site) :deep(:where(:focus-visible)) {\n outline: 2px solid var(--focus-ring);\n outline-offset: 3px;\n }\n\n /* The operator-mode container recolors every focused button with an\n unlayered button:focus:not(:disabled) rule, at (0,2,1). This rule\n paints the gallery's ring one notch above that, at (0,2,2) with the\n scope attribute, so a class rule a component sets on its own buttons,\n at (0,3,0) or more, still wins. :not(svg) is only that notch. */\n :where(.choreo-site) :deep(button:focus-visible:not(svg)) {\n outline-color: var(--focus-ring);\n }\n\n /* glimmer-motion marks the document while a drag is held, so nothing in\n the gallery selects under the pointer mid-drag */\n html[data-gm-dragging] .choreo-site,\n html[data-gm-dragging] .choreo-site :deep(*) {\n -webkit-user-select: none !important;\n user-select: none !important;\n -webkit-touch-callout: none !important;\n }\n\n /* The syntax spans come from highlightSample as trusted HTML, so they\n carry no component's scope; every code box in the gallery shares\n these colors. */\n .choreo-site :deep(.syn-tag),\n .choreo-site :deep(.syn-arg),\n .choreo-site :deep(.syn-keyword) {\n color: var(--iris-ink);\n }\n\n .choreo-site :deep(.syn-attr) {\n color: color-mix(in srgb, var(--ink) 80%, var(--bg-page));\n }\n\n .choreo-site :deep(.syn-string) {\n color: var(--syn-string);\n }\n\n .choreo-site :deep(.syn-helper),\n .choreo-site :deep(.syn-mustache) {\n color: var(--ember-ink);\n }\n\n .choreo-site :deep(.syn-number) {\n color: var(--steel-ink);\n }\n\n .choreo-site :deep(.syn-comment) {\n color: var(--ink-faint);\n }\n\n .choreo-site :deep(.syn-punct) {\n color: var(--ink-dim);\n }\n </style>\n</template>;\n";
342
+ var choreo_root_default = "import type { TOC } from '@ember/component/template-only';\nimport { MotionConfig } from 'glimmer-motion';\n\nimport { theme } from '../lib/theme';\n\ninterface Signature {\n Blocks: { default: [] };\n Element: HTMLDivElement;\n}\n\n/**\n * The boundary every piece of the gallery renders inside: the palette, the\n * type, and the few element defaults the demos were designed against.\n *\n * The tokens are custom properties declared on this element, so everything\n * inside it — the shell's own components and every demo stage — inherits them\n * without the gallery ever styling the host's document. The theme switch is\n * this element's `data-theme`.\n */\nexport const ChoreoRoot: TOC<Signature> = <template>\n <div\n class='choreo-site'\n data-choreo-site\n data-test-choreo-site\n data-theme={{theme.resolved}}\n ...attributes\n >\n <MotionConfig @reducedMotion='user'>\n {{yield}}\n </MotionConfig>\n </div>\n <style scoped>\n .choreo-site {\n color-scheme: dark;\n /* Dark surfaces are a warm charcoal ladder, not near-black.\n well < bg < page < elev < spot, so recessed chrome still sinks and\n cards still lift, without a black floor. */\n --bg-well: #221e1a;\n --bg: #2a2521;\n --bg-elev: #3a342e;\n --bg-spot: #443c35;\n /* Playhead's own surfaces, a shade above the shared ladder so its\n small UI sits ON the page rather than cutting a hole in it. */\n --ph-card-top: #3d362f;\n --ph-card-bot: #322c27;\n --ph-go-top: #4a4139;\n --ph-go-bot: #2c2722;\n --ph-receipt-top: #403830;\n --ph-receipt-bot: #2e2924;\n --ph-row: rgba(0, 0, 0, 0.16);\n --ph-groove: rgba(0, 0, 0, 0.22);\n --ph-btn: rgba(0, 0, 0, 0.26);\n /* the page's own resting tone. --bg is the recessed canvas; cards use\n --bg-elev, a notch above this, so they lift instead of sinking. */\n --bg-page: #2e2924;\n --ink: #f3ece3;\n /* a step between --ink and --ink-dim, for the notes' headings */\n --ink-soft: #e6dfd6;\n --ink-dim: #d2c9bf;\n --ink-faint: #b8aea3;\n --line: rgba(var(--ink-rgb), 0.09);\n --line-strong: rgba(var(--ink-rgb), 0.16);\n /* bare triplets for rgba(var(--x-rgb), a) call sites that want an\n alpha no token covers; redefined per theme so they repaint */\n --bg-rgb: 42, 37, 33;\n --ink-rgb: 243, 236, 227;\n /* Shadows were picked against a dark page. Light mode scales the\n alpha and warms the tint; in dark the multiplier is 1. */\n --shadow-rgb: 0, 0, 0;\n --shadow-a: 1;\n /* a raised panel goes lighter than the page in dark mode and darker\n in light mode; this is that one bit, so panels flip together */\n --surface-tint-rgb: 255, 255, 255;\n --ember: #ff3b1f;\n --ember-hot: #ff6a3a;\n --copper: #e4a35a;\n --iris: #9667f7;\n --steel: #8b969c;\n --teal: #4fb8a6;\n --azure: #6ea8f0;\n /* An accent as TEXT. The accents above are tuned as fills and rules;\n small type in them needs 4.5:1 on --bg, --bg-page, --bg-elev and the\n faint tints laid over them (a code box, a tile's meta, an API pill), so\n each one an element sets text in has an -ink twin, lifted here and\n darkened in light mode. Text reads the -ink token; fills,\n rules and glows keep the accent. */\n --ember-ink: #ff7a4e;\n --copper-ink: var(--copper);\n --iris-ink: #af8bf9;\n --steel-ink: #96a0a5;\n /* the small uppercase label over a section, a tile or a code sample */\n --eyebrow: var(--ember-ink);\n /* ember text on an ember-tinted fill, such as a diagram's hot box */\n --hot-ink: #ff9370;\n /* the theme's focus color, which Boxel's own components read too; a\n focus indicator needs 3:1 against whatever it is drawn over */\n --ring: var(--ember-hot);\n --glow: rgba(255, 59, 31, 0.38);\n /* the mark's fading tail and its inner orbit. The -dark pair holds in\n either theme, for a mark that sits on a dark ground. */\n --mark-tail-dark: rgba(255, 59, 31, 0.35);\n --mark-orbit-dark: rgba(139, 150, 156, 0.75);\n --mark-tail: var(--mark-tail-dark);\n --mark-orbit: var(--mark-orbit-dark);\n --syn-string: #ffb36a;\n --gradient-tail: var(--ink);\n --font-display: 'Syne', 'Avenir Next', sans-serif;\n --font-display-alt: 'Archivo', 'Avenir Next Condensed', sans-serif;\n --font: 'IBM Plex Sans', 'Helvetica Neue', sans-serif;\n --font-mono: 'IBM Plex Mono', ui-monospace, monospace;\n --ease: cubic-bezier(0.22, 1, 0.36, 1);\n --page: 1180px;\n /* one flat color plus two soft radial glows — no texture, so the\n ground is as composable as any other layer */\n --wash:\n radial-gradient(\n 1200px 640px at 12% -10%,\n rgba(255, 59, 31, 0.16),\n transparent 55%\n ),\n radial-gradient(\n 900px 480px at 110% 8%,\n rgba(228, 163, 90, 0.08),\n transparent 50%\n ),\n var(--bg-page);\n\n position: relative;\n min-height: 100%;\n color: var(--ink);\n font-family: var(--font);\n font-size: 16px;\n line-height: 1.5;\n background: var(--wash);\n transition:\n background 220ms var(--ease),\n color 220ms var(--ease);\n }\n\n /* Light mode. The accents carry over unchanged as fills and rules; every\n ink that sets text gets a darker value that holds 4.5:1 on cream. */\n .choreo-site[data-theme='light'] {\n color-scheme: light;\n --bg-well: #e2dccf;\n --bg: #efe9df;\n --bg-elev: #f7f3ec;\n --bg-spot: #fbf8f3;\n --shadow-rgb: 72, 56, 44;\n --shadow-a: 0.3;\n --ph-card-top: var(--bg-spot);\n --ph-card-bot: var(--bg-well);\n --ph-go-top: var(--bg-spot);\n --ph-go-bot: var(--bg-well);\n --ph-receipt-top: var(--bg-spot);\n --ph-receipt-bot: var(--bg-well);\n --ph-row: var(--bg-well);\n --ph-groove: var(--bg-well);\n --ph-btn: var(--bg-well);\n --bg-page: #f2ece1;\n --ink: #211d18;\n --ink-soft: #47423a;\n --ink-dim: #5e564b;\n --ink-faint: #6d6458;\n --line: rgba(33, 29, 24, 0.1);\n --line-strong: rgba(33, 29, 24, 0.18);\n --bg-rgb: 239, 233, 223;\n --ink-rgb: 33, 29, 24;\n --surface-tint-rgb: 33, 29, 24;\n --ember-ink: #bf2e00;\n --copper-ink: #905824;\n --iris-ink: #7334f5;\n --steel-ink: #5e686e;\n --hot-ink: #a02600;\n --ring: var(--ember-ink);\n --mark-tail: rgba(255, 59, 31, 0.5);\n --mark-orbit: rgba(94, 104, 110, 0.85);\n --syn-string: #a24f00;\n --gradient-tail: var(--copper);\n --lightbox-ring: rgba(33, 29, 24, 0.12);\n }\n\n /* The element defaults the demos were designed against. :where() gives\n them no more weight than a bare element selector, so any class rule a\n component sets on its own elements outranks them. */\n .choreo-site,\n :where(.choreo-site) :deep(:where(*)),\n :where(.choreo-site) :deep(:where(*)::before),\n :where(.choreo-site) :deep(:where(*)::after) {\n box-sizing: border-box;\n }\n\n :where(.choreo-site) :deep(:where(a)) {\n color: inherit;\n text-decoration: none;\n }\n\n :where(.choreo-site) :deep(:where(button, input)) {\n font: inherit;\n color: inherit;\n }\n\n :where(.choreo-site) :deep(:where(button)) {\n cursor: pointer;\n }\n\n /* No double-tap-to-zoom on anything you are meant to tap: Safari's\n wait for a second tap reads as lag on demos you tap repeatedly. */\n :where(.choreo-site)\n :deep(:where(button, a, select, summary, [role='button'])) {\n touch-action: manipulation;\n }\n\n :where(.choreo-site) :deep(:where(:focus-visible)) {\n outline: 2px solid var(--ring);\n outline-offset: 3px;\n }\n\n /* The operator-mode container recolors every focused button with an\n unlayered button:focus:not(:disabled) rule, at (0,2,1). This rule\n paints the gallery's ring one notch above that, at (0,2,2) with the\n scope attribute, so a class rule a component sets on its own buttons,\n at (0,3,0) or more, still wins. :not(svg) is only that notch. */\n :where(.choreo-site) :deep(button:focus-visible:not(svg)) {\n outline-color: var(--ring);\n }\n\n /* glimmer-motion marks the document while a drag is held, so nothing in\n the gallery selects under the pointer mid-drag */\n html[data-gm-dragging] .choreo-site,\n html[data-gm-dragging] .choreo-site :deep(*) {\n -webkit-user-select: none !important;\n user-select: none !important;\n -webkit-touch-callout: none !important;\n }\n\n /* The syntax spans come from highlightSample as trusted HTML, so they\n carry no component's scope; every code box in the gallery shares\n these colors. */\n .choreo-site :deep(.syn-tag),\n .choreo-site :deep(.syn-arg),\n .choreo-site :deep(.syn-keyword) {\n color: var(--iris-ink);\n }\n\n .choreo-site :deep(.syn-attr) {\n color: color-mix(in srgb, var(--ink) 80%, var(--bg-page));\n }\n\n .choreo-site :deep(.syn-string) {\n color: var(--syn-string);\n }\n\n .choreo-site :deep(.syn-helper),\n .choreo-site :deep(.syn-mustache) {\n color: var(--ember-ink);\n }\n\n .choreo-site :deep(.syn-number) {\n color: var(--steel-ink);\n }\n\n .choreo-site :deep(.syn-comment) {\n color: var(--ink-faint);\n }\n\n .choreo-site :deep(.syn-punct) {\n color: var(--ink-dim);\n }\n </style>\n</template>;\n";
343
343
  //#endregion
344
344
  //#region ../choreo-gallery/realm/shell/code-box.gts?raw
345
345
  var code_box_default = "import type { TOC } from '@ember/component/template-only';\n\nimport { highlightSample } from '../lib/highlight';\n\ninterface Signature {\n Args: {\n /** what the snippet is FROM — a filename, or the language for a sketch */\n label?: string;\n source: string | null | undefined;\n };\n Element: HTMLElement;\n}\n\n/**\n * The gallery's code box: a label over a highlighted, copyable snippet.\n *\n * One component, so the usage example under every stage, a walkthrough step\n * and a snippet quoted in a demo's notes are visibly the same object rather\n * than three kinds of code block.\n */\nexport const CodeBox: TOC<Signature> = <template>\n <section class='sample' aria-label='Code' ...attributes>\n <p class='sample-label'>{{if @label @label 'Glimmer'}}</p>\n <pre><code>{{highlightSample @source}}</code></pre>\n </section>\n <style scoped>\n .sample {\n margin-top: 22px;\n border: 1px solid var(--line);\n border-radius: 18px;\n overflow: hidden;\n /* the same faint light veil as a demo's own stage */\n background: rgba(var(--surface-tint-rgb), 0.03);\n }\n\n .sample-label {\n margin: 0;\n padding: 10px 16px 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n pre {\n margin: 0;\n padding: 12px 16px 16px;\n overflow-x: auto;\n }\n\n code {\n font-family: var(--font-mono);\n font-size: 12px;\n line-height: 1.55;\n color: var(--ink);\n white-space: pre;\n -webkit-user-select: text;\n user-select: text;\n }\n\n @media (max-width: 720px) {\n code {\n font-size: 11px;\n }\n }\n </style>\n</template>;\n\nexport default CodeBox;\n";
@@ -348,7 +348,7 @@ var code_box_default = "import type { TOC } from '@ember/component/template-only
348
348
  var demo_link_default = "import { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\n\nimport type { GalleryDemo } from '../demo';\n\ninterface Signature {\n Args: {\n demo: GalleryDemo;\n /** opens the demo card through the host */\n onOpen: (demo: GalleryDemo) => void;\n };\n Blocks: { default: [] };\n Element: HTMLAnchorElement;\n}\n\n/**\n * A link to another demo's card. A plain click hands the card to the host,\n * which opens it in the stack (or, on a published site, as the next page);\n * a modified click (new tab, new window) is left to the browser and opens the\n * card at its own URL.\n */\nexport class DemoLink extends Component<Signature> {\n follow = (event: Event) => {\n if (\n !(event instanceof MouseEvent) ||\n event.button !== 0 ||\n event.metaKey ||\n event.ctrlKey ||\n event.shiftKey ||\n event.altKey\n ) {\n return;\n }\n event.preventDefault();\n this.args.onOpen(this.args.demo);\n };\n\n <template>\n <a href={{@demo.id}} ...attributes {{on 'click' this.follow}}>{{yield}}</a>\n </template>\n}\n";
349
349
  //#endregion
350
350
  //#region ../choreo-gallery/realm/shell/demo-page.gts?raw
351
- var demo_page_default = "import { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\n\nimport type { GalleryDemo, Near } from '../demo';\nimport { FilmLink } from '../lib/film-link';\nimport { labSlug, LabState } from '../lib/lab';\nimport { type Theater, theaterView } from '../lib/theater';\nimport { ChoreoMark } from './choreo-mark';\nimport { CodeBox } from './code-box';\nimport { DemoLink } from './demo-link';\nimport { DemoStage } from './demo-stage';\nimport { GuideLink } from './guide-link';\nimport { LabPanel } from './lab-panel';\nimport { LabToggle } from './lab-toggle';\nimport { SpeedPicker, startPage } from './speed-picker';\n\ninterface Signature {\n Args: {\n demo: GalleryDemo;\n /** the demos either side of this one in its gallery's order */\n near?: Near;\n /** opens a demo card through the host; without it there is no pager */\n onOpen?: (demo: GalleryDemo) => void;\n theater: Theater;\n };\n}\n\n/**\n * One demo, at full size: what it is, the live stage, how to write it, and —\n * for the demos that bring one — how it works.\n */\nexport class DemoPage extends Component<Signature> {\n /** the stage's film, when it has one, for the notes' join triggers */\n readonly filmLink = new FilmLink();\n readonly lab = new LabState();\n\n constructor(owner: unknown, args: Signature['Args']) {\n super(owner as never, args);\n startPage();\n }\n\n get labSlug(): string | undefined {\n return labSlug(this.args.demo);\n }\n\n get labOpen(): boolean {\n return this.lab.isOpen(this.labSlug);\n }\n\n toggleLab = () => this.lab.toggle(this.labSlug);\n\n get Notes() {\n return (this.args.demo.constructor as typeof GalleryDemo).notes;\n }\n\n get well() {\n return (this.args.demo.constructor as typeof GalleryDemo).well;\n }\n\n /** only a demo with a film in its stage has a theater to enter */\n get inTheater(): boolean {\n return this.args.demo.theater === true && this.args.theater.on;\n }\n\n leaveTheater = () => this.args.theater.enter(false);\n\n /** whether there is a neighbour for the pager to open */\n get paged(): boolean {\n return Boolean(this.args.near?.prev || this.args.near?.next);\n }\n\n /**\n * the notes' join triggers, while the stage's film frame is attached; a\n * join plays only once the viewer has opened that film\n */\n get preview() {\n return this.filmLink.live ? this.filmLink.preview : undefined;\n }\n\n <template>\n {{#let @demo.slug as |slug|}}\n {{! THE BODY IS A WRAPPER THAT USUALLY IS NOT THERE: `display:\n contents` generates no box, so the page lays out as if it were absent\n — and in theater it becomes a flex column, which lets the stage be\n re-ORDERED to the front without being re-PARENTED. Moving an iframe in\n the DOM reloads it, and a film that restarts when you ask to see it\n bigger is not a film you can watch. }}\n <div\n class='demo-body {{if this.inTheater \"is-theater\"}}'\n data-test-demo-body\n data-test-theater={{this.inTheater}}\n >\n <article class='demo-head' data-demo={{slug}}>\n {{#if @onOpen}}\n {{#if @near.next}}\n {{! the pager is at the foot of the page, past the code — a long\n way to scroll to say \"next\". The same link, where you are\n already looking. }}\n <div class='demo-nav'>\n <DemoLink\n @demo={{@near.next}}\n @onOpen={{@onOpen}}\n class='back next-demo'\n >\n Next\n <b>{{@near.next.title}}</b>\n →\n </DemoLink>\n </div>\n {{/if}}\n {{/if}}\n <div class='demo-meta'>\n <p class='kicker'>\n {{@demo.group}}\n {{#if this.Notes}}\n <span class='kicker-badge'>Deep Dive</span>\n {{/if}}\n </p>\n <h1>{{@demo.title}}</h1>\n <p class='lede'>{{@demo.lede}}</p>\n </div>\n <div class='apis'>\n {{#each @demo.apis as |api|}}\n <code class='api'>{{api}}</code>\n {{/each}}\n </div>\n </article>\n {{#if @demo.slowmo}}\n <SpeedPicker />\n {{/if}}\n <p class='demo-links'>\n {{#if this.labSlug}}\n <LabToggle @open={{this.labOpen}} @onToggle={{this.toggleLab}} />\n {{/if}}\n <GuideLink @demo={{@demo}} />\n </p>\n <div\n class='stage-row {{if this.labOpen \"with-lab\"}}'\n data-test-stage-row\n {{theaterView}}\n >\n <div\n class='stage-wrap'\n data-demo-stage\n data-test-stage\n data-well={{this.well}}\n {{@theater.stage}}\n >\n {{#each (array this.lab.replays) key='@identity'}}\n <DemoStage\n @demo={{@demo}}\n @face='stage'\n @filmLink={{this.filmLink}}\n @theater={{@theater}}\n />\n {{/each}}\n {{! THE MARK, in theater only, where the site's bar has stepped out.\n It is also the way out, because the thing a viewer wants after\n the film is how it was made. }}\n {{#if this.inTheater}}\n <div class='theater-mark'>\n <ChoreoMark />\n <button\n type='button'\n class='theater-built'\n data-test-theater-exit\n {{on 'click' this.leaveTheater}}\n >How this is built</button>\n </div>\n {{/if}}\n </div>\n {{#if this.labOpen}}\n {{#if this.labSlug}}\n <LabPanel\n @slug={{this.labSlug}}\n @title={{@demo.title}}\n @onReplay={{this.lab.replay}}\n />\n {{/if}}\n {{/if}}\n </div>\n <CodeBox @source={{@demo.sample}} />\n {{! ...and, for a demo whose subject is its DATA, the data }}\n {{#if @demo.walkthrough.length}}\n <section class='walk' aria-label='Walkthrough'>\n {{#each @demo.walkthrough as |step|}}\n <p class='walk-note'>{{step.note}}</p>\n <CodeBox @label={{step.label}} @source={{step.source}} />\n {{/each}}\n </section>\n {{/if}}\n {{! How to use it, then how it works — in that order, because nobody\n needs the second one to get started }}\n {{#if this.Notes}}\n <this.Notes @preview={{this.preview}} />\n {{/if}}\n {{#if @onOpen}}\n {{#if this.paged}}\n <nav class='pager' aria-label='More demos'>\n {{#if @near.prev}}\n <DemoLink @demo={{@near.prev}} @onOpen={{@onOpen}}>\n ←\n {{@near.prev.title}}\n </DemoLink>\n {{else}}\n <span></span>\n {{/if}}\n {{#if @near.next}}\n <DemoLink @demo={{@near.next}} @onOpen={{@onOpen}}>\n {{@near.next.title}}\n →\n </DemoLink>\n {{/if}}\n </nav>\n {{/if}}\n {{/if}}\n </div>\n {{/let}}\n <style scoped>\n .demo-head {\n display: flex;\n flex-direction: column;\n gap: 18px;\n margin-bottom: 28px;\n }\n\n .demo-meta {\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n\n /* \"next\" on a line of its own above the title */\n .demo-nav {\n display: flex;\n align-items: baseline;\n justify-content: flex-end;\n gap: 16px;\n padding-top: 10px;\n }\n\n .back {\n width: fit-content;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-dim);\n text-decoration: underline;\n text-decoration-color: var(--line-strong);\n text-underline-offset: 3px;\n }\n\n @media (hover: hover) {\n .back:hover {\n color: var(--ink);\n }\n }\n\n .next-demo {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n }\n\n .next-demo b {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 400;\n letter-spacing: 0.08em;\n color: var(--ink);\n }\n\n @media (hover: hover) {\n .next-demo:hover b {\n color: var(--copper-ink);\n }\n }\n\n .kicker {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .kicker::before {\n content: '';\n width: 18px;\n height: 1px;\n background: var(--ember);\n box-shadow: 0 0 12px var(--glow);\n }\n\n .kicker-badge {\n display: inline-block;\n margin-left: 6px;\n padding: 2px 8px;\n font-size: 10px;\n font-family: var(--font-mono);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ember-ink);\n border: 1px solid color-mix(in srgb, var(--ember-ink) 55%, transparent);\n border-radius: 3px;\n vertical-align: middle;\n }\n\n h1 {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n font-size: clamp(2.4rem, 6vw, 4.2rem);\n letter-spacing: -0.045em;\n line-height: 0.92;\n }\n\n .lede {\n max-width: 38rem;\n margin: 0;\n color: var(--ink-dim);\n font-size: 1.125rem;\n }\n\n .apis {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n }\n\n .api {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--iris-ink);\n background: rgba(150, 103, 247, 0.1);\n border: 1px solid rgba(150, 103, 247, 0.3);\n border-radius: 6px;\n padding: 3px 7px;\n }\n\n .demo-links {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 12px 24px;\n margin: 0 0 16px;\n }\n\n .stage-row {\n position: relative;\n }\n\n /* the lab sits beside the stage, and under it where the page is too\n narrow for both */\n .stage-row.with-lab {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: start;\n gap: 16px;\n }\n\n @media (max-width: 760px) {\n .stage-row.with-lab {\n grid-template-columns: minmax(0, 1fr);\n }\n }\n\n .stage-wrap {\n position: relative;\n /* the demo page's platter, so a stage's platter-scoped container\n queries answer here too — inline-size, because this box's height\n depends on what the stage needs */\n container-name: platter;\n container-type: inline-size;\n min-height: min(72vh, 680px);\n border: 1px solid var(--line);\n border-radius: 22px;\n /* clip, not hidden: a hidden box is still a scroll container, and\n would scroll to reveal a focused control a demo's transform has\n carried past the edge */\n overflow: clip;\n background:\n radial-gradient(\n 70% 60% at 50% 40%,\n rgba(255, 59, 31, 0.1),\n transparent 60%\n ),\n var(--bg);\n }\n\n /* A FILM IS A WIDE FRAME. Towers is composed for a picture about\n sixteen by nine — the tower left of centre with its base on the\n ground, the type owning the right third — and the default well is a\n letterbox that crops the stone off the bottom. So its well takes the\n film's own aspect, capped so it never outgrows the screen. */\n .stage-wrap[data-well='wide'] {\n aspect-ratio: 16 / 9;\n min-height: 0;\n max-height: min(88vh, 1040px);\n }\n\n /* THE BASILICA IS TALL, and its film is composed for that: the towers\n run to the top of the frame and the rail sits under them, so its\n well is a shade taller than sixteen by nine, with a higher ceiling */\n .stage-wrap[data-well='tall'] {\n aspect-ratio: 16 / 10.5;\n min-height: 0;\n max-height: min(92vh, 1180px);\n }\n\n /* A LAPTOP IS A WIDE, SHORT SUBJECT ON A TALL PLATTER. A machine seen\n from a low three-quarter angle wants headroom above the lid and floor\n under the base — without it the framing solver keeps pulling the\n camera back to fit the height, and the drawing on the screen goes too\n small to read. */\n .stage-wrap[data-well='laptop'] {\n min-height: min(84vh, 860px);\n }\n\n /* ...and on a narrow screen a device's well takes the shape of the\n device. The flat laptop is 1178x838 scaled to fit, so on a phone it\n is width-bound, and a tall well then holds a very small machine with\n a band of empty air above and below it. The phone is the opposite\n shape: a landscape well holds a very small phone with air either\n side, and a tall one lets the device fill the frame. */\n @media (max-width: 760px) {\n .stage-wrap[data-well='laptop'] {\n min-height: 0;\n aspect-ratio: 1178 / 838;\n }\n\n .stage-wrap[data-well='phone'] {\n min-height: 0;\n aspect-ratio: 5 / 7;\n }\n }\n\n /* ── Theater ────────────────────────────────────────────────────\n The film's page with the stage re-ordered to the front. Everything\n else keeps its order and sits below, which makes the mode a scroll\n rather than a destination. */\n .demo-body {\n display: contents;\n }\n\n .demo-body.is-theater {\n display: flex;\n flex-direction: column;\n }\n\n /* EDGE TO EDGE: out of the page's gutter to the full width of the\n site, which `theaterView` publishes as --site-w */\n .demo-body.is-theater .stage-row {\n order: -1;\n container-type: inline-size;\n margin-inline: calc((100% - var(--site-w, 100%)) / 2);\n }\n\n /* THE WELL'S ASPECT IS FOR A WELL. In theater the frame is as tall as\n what the viewer can see of the card — the element that scrolls it,\n published as --view-h, which in the host sits under the host's chrome\n and the card's header — capped at a square so a portrait phone does\n not turn the film into a tall strip. A document that scrolls itself\n falls back to the window, where dvh follows the visible viewport as a\n phone's browser chrome opens and closes. The window caps it either\n way: the scroller is the nearest ancestor set to scroll, and one that\n grows with its content would publish the whole page's height. The\n picture reframes to this box. */\n .demo-body.is-theater .stage-wrap {\n height: min(100vh, 100cqi);\n height: min(var(--view-h, 100dvh), 100dvh, 100cqi);\n min-height: 0;\n max-height: none;\n aspect-ratio: auto;\n border: 0;\n border-radius: 0;\n }\n\n /* the film ends at the fold and this is the first thing under it */\n .demo-body.is-theater .demo-nav {\n padding-top: 42px;\n }\n\n /* LEGIBLE ON A FILM, not on a page. These pictures run from a chalk\n noon to a night, so neither the gallery's ink nor its paper can be\n relied on for contrast: the mark gets its own ground, a dark blurred\n pill that belongs to no theme and works over any frame. */\n .theater-mark {\n position: absolute;\n top: clamp(12px, 2vw, 22px);\n right: clamp(14px, 2.2vw, 26px);\n z-index: 7;\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 7px 13px 7px 10px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.16);\n background: rgba(14, 12, 10, 0.55);\n /* the pill is dark in either theme, so the mark wears its dark\n colors on it, and the type and its button's ring are light: the\n one color that clears 3:1 on the pill over any frame from black\n to white */\n --mark-tail: var(--mark-tail-dark);\n --mark-orbit: var(--mark-orbit-dark);\n --pill-ink: rgba(255, 250, 242, 0.96);\n --focus-ring: var(--pill-ink);\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n opacity: 0.9;\n transition:\n opacity 200ms ease,\n background 200ms ease;\n }\n\n .theater-mark:hover {\n opacity: 1;\n background: rgba(14, 12, 10, 0.72);\n }\n\n .theater-mark :deep(svg) {\n display: block;\n width: 22px;\n height: 22px;\n }\n\n .theater-built {\n padding: 0;\n border: 0;\n background: none;\n color: var(--pill-ink);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n white-space: nowrap;\n cursor: pointer;\n }\n\n .theater-built:hover {\n text-decoration: underline;\n text-underline-offset: 3px;\n }\n\n /* ── The deep dive ──────────────────────────────────────────────\n A demo's notes, under the usage example: set smaller than the page\n on purpose, because nobody needs how the machine works to get\n started. The notes are their own components, so these reach in. */\n .demo-body :deep(.dive) {\n display: flex;\n flex-direction: column;\n gap: 34px;\n margin-top: 40px;\n padding-top: 30px;\n border-top: 1px solid var(--line);\n font-size: 15px;\n line-height: 1.72;\n }\n\n .demo-body :deep(.dive-head) {\n display: flex;\n flex-direction: column;\n gap: 10px;\n max-width: 62ch;\n }\n\n .demo-body :deep(.dive-kicker) {\n margin: 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .demo-body :deep(.dive-head h2) {\n margin: 0;\n font-family: var(--font-display);\n font-size: clamp(22px, 3.4vw, 30px);\n font-weight: 700;\n line-height: 1.14;\n letter-spacing: -0.01em;\n text-wrap: balance;\n color: var(--ink-soft);\n }\n\n .demo-body :deep(.dive-lede) {\n margin: 0;\n font-size: 15px;\n line-height: 1.65;\n color: var(--ink-dim);\n }\n\n .demo-body :deep(.dd) {\n display: flex;\n flex-direction: column;\n gap: 14px;\n }\n\n /* the eyebrow names the function the section explains */\n .demo-body :deep(.dd-fn) {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.06em;\n color: var(--eyebrow);\n }\n\n .demo-body :deep(.dd-fn::after) {\n flex: 1;\n height: 1px;\n background: var(--line);\n content: '';\n }\n\n .demo-body :deep(.dd-fn span) {\n color: var(--ink-faint);\n }\n\n .demo-body :deep(.dd h3) {\n max-width: 30ch;\n margin: 0;\n font-family: var(--font-display);\n font-size: 19px;\n font-weight: 700;\n line-height: 1.22;\n text-wrap: balance;\n color: var(--ink-soft);\n }\n\n /* prose keeps a reading measure */\n .demo-body :deep(.dd-col) {\n display: flex;\n flex-direction: column;\n gap: 12px;\n max-width: 68ch;\n }\n\n .demo-body :deep(.dd-col p) {\n margin: 0;\n color: var(--ink-dim);\n }\n\n .demo-body :deep(.dd-col strong) {\n font-weight: 600;\n color: var(--ink-soft);\n }\n\n .demo-body :deep(.dd-col em) {\n font-style: normal;\n color: var(--copper-ink);\n }\n\n .demo-body :deep(.dd code) {\n font-family: var(--font-mono);\n font-size: 0.86em;\n color: var(--copper-ink);\n }\n\n /* a code box inside a note is the same object as the one above the\n dive; only its spacing changes, because the column owns the gaps */\n .demo-body :deep(.dd .sample) {\n margin-top: 0;\n }\n\n /* The notes' diagrams: a shared vocabulary of plates, boxes, rules and\n labels, drawn in each note's inline SVG and styled once here. */\n .demo-body :deep(.dd-fig) {\n margin: 2px 0 0;\n padding: 16px 16px 13px;\n border: 1px solid var(--line);\n border-radius: 16px;\n /* a light tint, capped at 4%: a dark tint over an already dark page\n does not read as a surface of its own */\n background: rgba(var(--surface-tint-rgb), 0.04);\n /* a wide drawing scrolls inside its own box rather than the page */\n overflow-x: auto;\n }\n\n .demo-body :deep(.dd-fig figcaption) {\n max-width: 76ch;\n margin-top: 13px;\n padding-top: 11px;\n border-top: 1px solid var(--line);\n font-family: var(--font-mono);\n font-size: 11px;\n line-height: 1.6;\n color: var(--ink-faint);\n }\n\n .demo-body :deep(.dd-fig figcaption b) {\n font-weight: 500;\n color: var(--ink-dim);\n }\n\n /* Capped, so a drawing stays subordinate to the prose it explains. At full\n page width a 900-unit viewBox renders its 11px labels larger than the 14px\n body text, which makes the diagram read as the main event. */\n .demo-body :deep(.dg) {\n display: block;\n width: 100%;\n /* below this the labels collide; the figure scrolls instead of squashing */\n min-width: 560px;\n max-width: 760px;\n height: auto;\n margin: 0 auto;\n }\n\n .demo-body :deep(.dg-plate) {\n fill: var(--bg);\n stroke: var(--line);\n }\n\n .demo-body :deep(.dg-box) {\n fill: var(--bg-elev);\n stroke: var(--line-strong);\n }\n\n .demo-body :deep(.dg-boxcop) {\n fill: var(--bg-elev);\n stroke: var(--copper);\n }\n\n .demo-body :deep(.dg-hot) {\n fill: rgba(255, 59, 31, 0.16);\n stroke: var(--ember);\n }\n\n .demo-body :deep(.dg-band) {\n fill: rgba(var(--ink-rgb), 0.04);\n }\n\n .demo-body :deep(.dg-rule) {\n fill: none;\n stroke: var(--line-strong);\n }\n\n .demo-body :deep(.dg-hair) {\n fill: none;\n stroke: var(--line);\n }\n\n .demo-body :deep(.dg-cop) {\n fill: none;\n stroke: var(--copper);\n }\n\n .demo-body :deep(.dg-emberline) {\n fill: none;\n stroke: var(--ember);\n }\n\n .demo-body :deep(.dg-hotline) {\n fill: none;\n stroke: var(--ember-hot);\n stroke-width: 2;\n }\n\n .demo-body :deep(.dg-inkline) {\n fill: none;\n stroke: var(--ink);\n stroke-width: 1.6;\n }\n\n .demo-body :deep(.dg-dash) {\n stroke-dasharray: 3 3;\n }\n\n .demo-body :deep(.dg-dot) {\n fill: var(--ember-hot);\n }\n\n .demo-body :deep(.dg-copdot) {\n fill: var(--copper);\n }\n\n .demo-body :deep(.dg-faintdot) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-arrow) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-hand) {\n fill: var(--ink);\n stroke: var(--bg);\n stroke-width: 0.8;\n }\n\n .demo-body :deep(.dg-t) {\n font-family: var(--font-mono);\n font-size: 11px;\n fill: var(--ink);\n }\n\n /* a hot label most often sits in a .dg-hot box */\n .demo-body :deep(.dg-t.is-hot) {\n fill: var(--hot-ink);\n }\n\n .demo-body :deep(.dg-t.is-dim) {\n fill: var(--ink-dim);\n }\n\n .demo-body :deep(.dg-t.is-faint) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-t.is-cop) {\n fill: var(--copper-ink);\n }\n\n .demo-body :deep(.dg-eb) {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 1.4px;\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-h) {\n font-family: var(--font-display);\n font-size: 15px;\n font-weight: 700;\n fill: var(--ink);\n }\n\n .demo-body :deep(.dg-h.is-hot) {\n fill: var(--hot-ink);\n }\n\n .walk {\n margin-top: 26px;\n }\n\n .walk-note {\n max-width: 68ch;\n margin: 26px 0 10px;\n color: var(--ink-dim);\n font-size: 14px;\n line-height: 1.6;\n }\n\n .walk-note:first-child {\n margin-top: 0;\n }\n\n .pager {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n gap: 16px;\n margin-top: 22px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n .pager > :deep(a) {\n text-decoration: underline;\n text-decoration-color: var(--line-strong);\n text-underline-offset: 3px;\n min-width: 0;\n max-width: 48%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n @media (hover: hover) {\n .pager > :deep(a:hover) {\n color: var(--ink);\n }\n }\n\n /* a phone frame has no room for a byline beside the mark */\n @media (max-width: 700px) {\n .theater-built {\n display: none;\n }\n }\n\n @media (max-width: 720px) {\n /* a text link is a 17px-tall tap target; the padding is the target */\n .demo-nav {\n margin-bottom: -8px;\n }\n\n .back {\n padding: 8px 0;\n }\n\n .next-demo {\n gap: 6px;\n }\n\n .next-demo b {\n max-width: 42vw;\n }\n\n .pager > :deep(a) {\n max-width: 46%;\n padding: 8px 0;\n }\n\n .stage-wrap {\n min-height: min(74vh, 620px);\n border-radius: 16px;\n }\n }\n </style>\n </template>\n}\n";
351
+ var demo_page_default = "import { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\n\nimport type { GalleryDemo, Near } from '../demo';\nimport { FilmLink } from '../lib/film-link';\nimport { labSlug, LabState } from '../lib/lab';\nimport { type Theater, theaterView } from '../lib/theater';\nimport { ChoreoMark } from './choreo-mark';\nimport { CodeBox } from './code-box';\nimport { DemoLink } from './demo-link';\nimport { DemoStage } from './demo-stage';\nimport { GuideLink } from './guide-link';\nimport { LabPanel } from './lab-panel';\nimport { LabToggle } from './lab-toggle';\nimport { SpeedPicker, startPage } from './speed-picker';\n\ninterface Signature {\n Args: {\n demo: GalleryDemo;\n /** the demos either side of this one in its gallery's order */\n near?: Near;\n /** opens a demo card through the host; without it there is no pager */\n onOpen?: (demo: GalleryDemo) => void;\n theater: Theater;\n };\n}\n\n/**\n * One demo, at full size: what it is, the live stage, how to write it, and —\n * for the demos that bring one — how it works.\n */\nexport class DemoPage extends Component<Signature> {\n /** the stage's film, when it has one, for the notes' join triggers */\n readonly filmLink = new FilmLink();\n readonly lab = new LabState();\n\n constructor(owner: unknown, args: Signature['Args']) {\n super(owner as never, args);\n startPage();\n }\n\n get labSlug(): string | undefined {\n return labSlug(this.args.demo);\n }\n\n get labOpen(): boolean {\n return this.lab.isOpen(this.labSlug);\n }\n\n toggleLab = () => this.lab.toggle(this.labSlug);\n\n get Notes() {\n return (this.args.demo.constructor as typeof GalleryDemo).notes;\n }\n\n get well() {\n return (this.args.demo.constructor as typeof GalleryDemo).well;\n }\n\n /** only a demo with a film in its stage has a theater to enter */\n get inTheater(): boolean {\n return this.args.demo.theater === true && this.args.theater.on;\n }\n\n leaveTheater = () => this.args.theater.enter(false);\n\n /** whether there is a neighbour for the pager to open */\n get paged(): boolean {\n return Boolean(this.args.near?.prev || this.args.near?.next);\n }\n\n /**\n * the notes' join triggers, while the stage's film frame is attached; a\n * join plays only once the viewer has opened that film\n */\n get preview() {\n return this.filmLink.live ? this.filmLink.preview : undefined;\n }\n\n <template>\n {{#let @demo.slug as |slug|}}\n {{! THE BODY IS A WRAPPER THAT USUALLY IS NOT THERE: `display:\n contents` generates no box, so the page lays out as if it were absent\n — and in theater it becomes a flex column, which lets the stage be\n re-ORDERED to the front without being re-PARENTED. Moving an iframe in\n the DOM reloads it, and a film that restarts when you ask to see it\n bigger is not a film you can watch. }}\n <div\n class='demo-body {{if this.inTheater \"is-theater\"}}'\n data-test-demo-body\n data-test-theater={{this.inTheater}}\n >\n <article class='demo-head' data-demo={{slug}}>\n {{#if @onOpen}}\n {{#if @near.next}}\n {{! the pager is at the foot of the page, past the code — a long\n way to scroll to say \"next\". The same link, where you are\n already looking. }}\n <div class='demo-nav'>\n <DemoLink\n @demo={{@near.next}}\n @onOpen={{@onOpen}}\n class='back next-demo'\n >\n Next\n <b>{{@near.next.title}}</b>\n →\n </DemoLink>\n </div>\n {{/if}}\n {{/if}}\n <div class='demo-meta'>\n <p class='kicker'>\n {{@demo.group}}\n {{#if this.Notes}}\n <span class='kicker-badge'>Deep Dive</span>\n {{/if}}\n </p>\n <h1>{{@demo.title}}</h1>\n <p class='lede'>{{@demo.lede}}</p>\n </div>\n <div class='apis'>\n {{#each @demo.apis as |api|}}\n <code class='api'>{{api}}</code>\n {{/each}}\n </div>\n </article>\n {{#if @demo.slowmo}}\n <SpeedPicker />\n {{/if}}\n <p class='demo-links'>\n {{#if this.labSlug}}\n <LabToggle @open={{this.labOpen}} @onToggle={{this.toggleLab}} />\n {{/if}}\n <GuideLink @demo={{@demo}} />\n </p>\n <div\n class='stage-row {{if this.labOpen \"with-lab\"}}'\n data-test-stage-row\n {{theaterView}}\n >\n <div\n class='stage-wrap'\n data-demo-stage\n data-test-stage\n data-well={{this.well}}\n {{@theater.stage}}\n >\n {{#each (array this.lab.replays) key='@identity'}}\n <DemoStage\n @demo={{@demo}}\n @face='stage'\n @filmLink={{this.filmLink}}\n @theater={{@theater}}\n />\n {{/each}}\n {{! THE MARK, in theater only, where the site's bar has stepped out.\n It is also the way out, because the thing a viewer wants after\n the film is how it was made. }}\n {{#if this.inTheater}}\n <div class='theater-mark'>\n <ChoreoMark />\n <button\n type='button'\n class='theater-built'\n data-test-theater-exit\n {{on 'click' this.leaveTheater}}\n >How this is built</button>\n </div>\n {{/if}}\n </div>\n {{#if this.labOpen}}\n {{#if this.labSlug}}\n <LabPanel\n @slug={{this.labSlug}}\n @title={{@demo.title}}\n @onReplay={{this.lab.replay}}\n />\n {{/if}}\n {{/if}}\n </div>\n <CodeBox @source={{@demo.sample}} />\n {{! ...and, for a demo whose subject is its DATA, the data }}\n {{#if @demo.walkthrough.length}}\n <section class='walk' aria-label='Walkthrough'>\n {{#each @demo.walkthrough as |step|}}\n <p class='walk-note'>{{step.note}}</p>\n <CodeBox @label={{step.label}} @source={{step.source}} />\n {{/each}}\n </section>\n {{/if}}\n {{! How to use it, then how it works — in that order, because nobody\n needs the second one to get started }}\n {{#if this.Notes}}\n <this.Notes @preview={{this.preview}} />\n {{/if}}\n {{#if @onOpen}}\n {{#if this.paged}}\n <nav class='pager' aria-label='More demos'>\n {{#if @near.prev}}\n <DemoLink @demo={{@near.prev}} @onOpen={{@onOpen}}>\n ←\n {{@near.prev.title}}\n </DemoLink>\n {{else}}\n <span></span>\n {{/if}}\n {{#if @near.next}}\n <DemoLink @demo={{@near.next}} @onOpen={{@onOpen}}>\n {{@near.next.title}}\n →\n </DemoLink>\n {{/if}}\n </nav>\n {{/if}}\n {{/if}}\n </div>\n {{/let}}\n <style scoped>\n .demo-head {\n display: flex;\n flex-direction: column;\n gap: 18px;\n margin-bottom: 28px;\n }\n\n .demo-meta {\n display: flex;\n flex-direction: column;\n gap: 18px;\n }\n\n /* \"next\" on a line of its own above the title */\n .demo-nav {\n display: flex;\n align-items: baseline;\n justify-content: flex-end;\n gap: 16px;\n padding-top: 10px;\n }\n\n .back {\n width: fit-content;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-dim);\n text-decoration: underline;\n text-decoration-color: var(--line-strong);\n text-underline-offset: 3px;\n }\n\n @media (hover: hover) {\n .back:hover {\n color: var(--ink);\n }\n }\n\n .next-demo {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n }\n\n .next-demo b {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n font-weight: 400;\n letter-spacing: 0.08em;\n color: var(--ink);\n }\n\n @media (hover: hover) {\n .next-demo:hover b {\n color: var(--copper-ink);\n }\n }\n\n .kicker {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .kicker::before {\n content: '';\n width: 18px;\n height: 1px;\n background: var(--ember);\n box-shadow: 0 0 12px var(--glow);\n }\n\n .kicker-badge {\n display: inline-block;\n margin-left: 6px;\n padding: 2px 8px;\n font-size: 10px;\n font-family: var(--font-mono);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ember-ink);\n border: 1px solid color-mix(in srgb, var(--ember-ink) 55%, transparent);\n border-radius: 3px;\n vertical-align: middle;\n }\n\n h1 {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n font-size: clamp(2.4rem, 6vw, 4.2rem);\n letter-spacing: -0.045em;\n line-height: 0.92;\n }\n\n .lede {\n max-width: 38rem;\n margin: 0;\n color: var(--ink-dim);\n font-size: 1.125rem;\n }\n\n .apis {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n }\n\n .api {\n font-family: var(--font-mono);\n font-size: 11px;\n color: var(--iris-ink);\n background: rgba(150, 103, 247, 0.1);\n border: 1px solid rgba(150, 103, 247, 0.3);\n border-radius: 6px;\n padding: 3px 7px;\n }\n\n .demo-links {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 12px 24px;\n margin: 0 0 16px;\n }\n\n .stage-row {\n position: relative;\n }\n\n /* the lab sits beside the stage, and under it where the page is too\n narrow for both */\n .stage-row.with-lab {\n display: grid;\n grid-template-columns: minmax(0, 1fr) auto;\n align-items: start;\n gap: 16px;\n }\n\n @media (max-width: 760px) {\n .stage-row.with-lab {\n grid-template-columns: minmax(0, 1fr);\n }\n }\n\n .stage-wrap {\n position: relative;\n /* the demo page's platter, so a stage's platter-scoped container\n queries answer here too — inline-size, because this box's height\n depends on what the stage needs */\n container-name: platter;\n container-type: inline-size;\n min-height: min(72vh, 680px);\n border: 1px solid var(--line);\n border-radius: 22px;\n /* clip, not hidden: a hidden box is still a scroll container, and\n would scroll to reveal a focused control a demo's transform has\n carried past the edge */\n overflow: clip;\n background:\n radial-gradient(\n 70% 60% at 50% 40%,\n rgba(255, 59, 31, 0.1),\n transparent 60%\n ),\n var(--bg);\n }\n\n /* A FILM IS A WIDE FRAME. Towers is composed for a picture about\n sixteen by nine — the tower left of centre with its base on the\n ground, the type owning the right third — and the default well is a\n letterbox that crops the stone off the bottom. So its well takes the\n film's own aspect, capped so it never outgrows the screen. */\n .stage-wrap[data-well='wide'] {\n aspect-ratio: 16 / 9;\n min-height: 0;\n max-height: min(88vh, 1040px);\n }\n\n /* THE BASILICA IS TALL, and its film is composed for that: the towers\n run to the top of the frame and the rail sits under them, so its\n well is a shade taller than sixteen by nine, with a higher ceiling */\n .stage-wrap[data-well='tall'] {\n aspect-ratio: 16 / 10.5;\n min-height: 0;\n max-height: min(92vh, 1180px);\n }\n\n /* A LAPTOP IS A WIDE, SHORT SUBJECT ON A TALL PLATTER. A machine seen\n from a low three-quarter angle wants headroom above the lid and floor\n under the base — without it the framing solver keeps pulling the\n camera back to fit the height, and the drawing on the screen goes too\n small to read. */\n .stage-wrap[data-well='laptop'] {\n min-height: min(84vh, 860px);\n }\n\n /* ...and on a narrow screen a device's well takes the shape of the\n device. The flat laptop is 1178x838 scaled to fit, so on a phone it\n is width-bound, and a tall well then holds a very small machine with\n a band of empty air above and below it. The phone is the opposite\n shape: a landscape well holds a very small phone with air either\n side, and a tall one lets the device fill the frame. */\n @media (max-width: 760px) {\n .stage-wrap[data-well='laptop'] {\n min-height: 0;\n aspect-ratio: 1178 / 838;\n }\n\n .stage-wrap[data-well='phone'] {\n min-height: 0;\n aspect-ratio: 5 / 7;\n }\n }\n\n /* ── Theater ────────────────────────────────────────────────────\n The film's page with the stage re-ordered to the front. Everything\n else keeps its order and sits below, which makes the mode a scroll\n rather than a destination. */\n .demo-body {\n display: contents;\n }\n\n .demo-body.is-theater {\n display: flex;\n flex-direction: column;\n }\n\n /* EDGE TO EDGE: out of the page's gutter to the full width of the\n site, which `theaterView` publishes as --site-w */\n .demo-body.is-theater .stage-row {\n order: -1;\n container-type: inline-size;\n margin-inline: calc((100% - var(--site-w, 100%)) / 2);\n }\n\n /* THE WELL'S ASPECT IS FOR A WELL. In theater the frame is as tall as\n what the viewer can see of the card — the element that scrolls it,\n published as --view-h, which in the host sits under the host's chrome\n and the card's header — capped at a square so a portrait phone does\n not turn the film into a tall strip. A document that scrolls itself\n falls back to the window, where dvh follows the visible viewport as a\n phone's browser chrome opens and closes. The window caps it either\n way: the scroller is the nearest ancestor set to scroll, and one that\n grows with its content would publish the whole page's height. The\n picture reframes to this box. */\n .demo-body.is-theater .stage-wrap {\n height: min(100vh, 100cqi);\n height: min(var(--view-h, 100dvh), 100dvh, 100cqi);\n min-height: 0;\n max-height: none;\n aspect-ratio: auto;\n border: 0;\n border-radius: 0;\n }\n\n /* the film ends at the fold and this is the first thing under it */\n .demo-body.is-theater .demo-nav {\n padding-top: 42px;\n }\n\n /* LEGIBLE ON A FILM, not on a page. These pictures run from a chalk\n noon to a night, so neither the gallery's ink nor its paper can be\n relied on for contrast: the mark gets its own ground, a dark blurred\n pill that belongs to no theme and works over any frame. */\n .theater-mark {\n position: absolute;\n top: clamp(12px, 2vw, 22px);\n right: clamp(14px, 2.2vw, 26px);\n z-index: 7;\n display: flex;\n align-items: center;\n gap: 9px;\n padding: 7px 13px 7px 10px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.16);\n background: rgba(14, 12, 10, 0.55);\n /* the pill is dark in either theme, so the mark wears its dark\n colors on it, and the type and its button's ring are light: the\n one color that clears 3:1 on the pill over any frame from black\n to white */\n --mark-tail: var(--mark-tail-dark);\n --mark-orbit: var(--mark-orbit-dark);\n --pill-ink: rgba(255, 250, 242, 0.96);\n --ring: var(--pill-ink);\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n opacity: 0.9;\n transition:\n opacity 200ms ease,\n background 200ms ease;\n }\n\n .theater-mark:hover {\n opacity: 1;\n background: rgba(14, 12, 10, 0.72);\n }\n\n .theater-mark :deep(svg) {\n display: block;\n width: 22px;\n height: 22px;\n }\n\n .theater-built {\n padding: 0;\n border: 0;\n background: none;\n color: var(--pill-ink);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n white-space: nowrap;\n cursor: pointer;\n }\n\n .theater-built:hover {\n text-decoration: underline;\n text-underline-offset: 3px;\n }\n\n /* ── The deep dive ──────────────────────────────────────────────\n A demo's notes, under the usage example: set smaller than the page\n on purpose, because nobody needs how the machine works to get\n started. The notes are their own components, so these reach in. */\n .demo-body :deep(.dive) {\n display: flex;\n flex-direction: column;\n gap: 34px;\n margin-top: 40px;\n padding-top: 30px;\n border-top: 1px solid var(--line);\n font-size: 15px;\n line-height: 1.72;\n }\n\n .demo-body :deep(.dive-head) {\n display: flex;\n flex-direction: column;\n gap: 10px;\n max-width: 62ch;\n }\n\n .demo-body :deep(.dive-kicker) {\n margin: 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--eyebrow);\n }\n\n .demo-body :deep(.dive-head h2) {\n margin: 0;\n font-family: var(--font-display);\n font-size: clamp(22px, 3.4vw, 30px);\n font-weight: 700;\n line-height: 1.14;\n letter-spacing: -0.01em;\n text-wrap: balance;\n color: var(--ink-soft);\n }\n\n .demo-body :deep(.dive-lede) {\n margin: 0;\n font-size: 15px;\n line-height: 1.65;\n color: var(--ink-dim);\n }\n\n .demo-body :deep(.dd) {\n display: flex;\n flex-direction: column;\n gap: 14px;\n }\n\n /* the eyebrow names the function the section explains */\n .demo-body :deep(.dd-fn) {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.06em;\n color: var(--eyebrow);\n }\n\n .demo-body :deep(.dd-fn::after) {\n flex: 1;\n height: 1px;\n background: var(--line);\n content: '';\n }\n\n .demo-body :deep(.dd-fn span) {\n color: var(--ink-faint);\n }\n\n .demo-body :deep(.dd h3) {\n max-width: 30ch;\n margin: 0;\n font-family: var(--font-display);\n font-size: 19px;\n font-weight: 700;\n line-height: 1.22;\n text-wrap: balance;\n color: var(--ink-soft);\n }\n\n /* prose keeps a reading measure */\n .demo-body :deep(.dd-col) {\n display: flex;\n flex-direction: column;\n gap: 12px;\n max-width: 68ch;\n }\n\n .demo-body :deep(.dd-col p) {\n margin: 0;\n color: var(--ink-dim);\n }\n\n .demo-body :deep(.dd-col strong) {\n font-weight: 600;\n color: var(--ink-soft);\n }\n\n .demo-body :deep(.dd-col em) {\n font-style: normal;\n color: var(--copper-ink);\n }\n\n .demo-body :deep(.dd code) {\n font-family: var(--font-mono);\n font-size: 0.86em;\n color: var(--copper-ink);\n }\n\n /* a code box inside a note is the same object as the one above the\n dive; only its spacing changes, because the column owns the gaps */\n .demo-body :deep(.dd .sample) {\n margin-top: 0;\n }\n\n /* The notes' diagrams: a shared vocabulary of plates, boxes, rules and\n labels, drawn in each note's inline SVG and styled once here. */\n .demo-body :deep(.dd-fig) {\n margin: 2px 0 0;\n padding: 16px 16px 13px;\n border: 1px solid var(--line);\n border-radius: 16px;\n /* a light tint, capped at 4%: a dark tint over an already dark page\n does not read as a surface of its own */\n background: rgba(var(--surface-tint-rgb), 0.04);\n /* a wide drawing scrolls inside its own box rather than the page */\n overflow-x: auto;\n }\n\n .demo-body :deep(.dd-fig figcaption) {\n max-width: 76ch;\n margin-top: 13px;\n padding-top: 11px;\n border-top: 1px solid var(--line);\n font-family: var(--font-mono);\n font-size: 11px;\n line-height: 1.6;\n color: var(--ink-faint);\n }\n\n .demo-body :deep(.dd-fig figcaption b) {\n font-weight: 500;\n color: var(--ink-dim);\n }\n\n /* Capped, so a drawing stays subordinate to the prose it explains. At full\n page width a 900-unit viewBox renders its 11px labels larger than the 14px\n body text, which makes the diagram read as the main event. */\n .demo-body :deep(.dg) {\n display: block;\n width: 100%;\n /* below this the labels collide; the figure scrolls instead of squashing */\n min-width: 560px;\n max-width: 760px;\n height: auto;\n margin: 0 auto;\n }\n\n .demo-body :deep(.dg-plate) {\n fill: var(--bg);\n stroke: var(--line);\n }\n\n .demo-body :deep(.dg-box) {\n fill: var(--bg-elev);\n stroke: var(--line-strong);\n }\n\n .demo-body :deep(.dg-boxcop) {\n fill: var(--bg-elev);\n stroke: var(--copper);\n }\n\n .demo-body :deep(.dg-hot) {\n fill: rgba(255, 59, 31, 0.16);\n stroke: var(--ember);\n }\n\n .demo-body :deep(.dg-band) {\n fill: rgba(var(--ink-rgb), 0.04);\n }\n\n .demo-body :deep(.dg-rule) {\n fill: none;\n stroke: var(--line-strong);\n }\n\n .demo-body :deep(.dg-hair) {\n fill: none;\n stroke: var(--line);\n }\n\n .demo-body :deep(.dg-cop) {\n fill: none;\n stroke: var(--copper);\n }\n\n .demo-body :deep(.dg-emberline) {\n fill: none;\n stroke: var(--ember);\n }\n\n .demo-body :deep(.dg-hotline) {\n fill: none;\n stroke: var(--ember-hot);\n stroke-width: 2;\n }\n\n .demo-body :deep(.dg-inkline) {\n fill: none;\n stroke: var(--ink);\n stroke-width: 1.6;\n }\n\n .demo-body :deep(.dg-dash) {\n stroke-dasharray: 3 3;\n }\n\n .demo-body :deep(.dg-dot) {\n fill: var(--ember-hot);\n }\n\n .demo-body :deep(.dg-copdot) {\n fill: var(--copper);\n }\n\n .demo-body :deep(.dg-faintdot) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-arrow) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-hand) {\n fill: var(--ink);\n stroke: var(--bg);\n stroke-width: 0.8;\n }\n\n .demo-body :deep(.dg-t) {\n font-family: var(--font-mono);\n font-size: 11px;\n fill: var(--ink);\n }\n\n /* a hot label most often sits in a .dg-hot box */\n .demo-body :deep(.dg-t.is-hot) {\n fill: var(--hot-ink);\n }\n\n .demo-body :deep(.dg-t.is-dim) {\n fill: var(--ink-dim);\n }\n\n .demo-body :deep(.dg-t.is-faint) {\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-t.is-cop) {\n fill: var(--copper-ink);\n }\n\n .demo-body :deep(.dg-eb) {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 1.4px;\n fill: var(--ink-faint);\n }\n\n .demo-body :deep(.dg-h) {\n font-family: var(--font-display);\n font-size: 15px;\n font-weight: 700;\n fill: var(--ink);\n }\n\n .demo-body :deep(.dg-h.is-hot) {\n fill: var(--hot-ink);\n }\n\n .walk {\n margin-top: 26px;\n }\n\n .walk-note {\n max-width: 68ch;\n margin: 26px 0 10px;\n color: var(--ink-dim);\n font-size: 14px;\n line-height: 1.6;\n }\n\n .walk-note:first-child {\n margin-top: 0;\n }\n\n .pager {\n display: flex;\n justify-content: space-between;\n flex-wrap: wrap;\n gap: 16px;\n margin-top: 22px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n .pager > :deep(a) {\n text-decoration: underline;\n text-decoration-color: var(--line-strong);\n text-underline-offset: 3px;\n min-width: 0;\n max-width: 48%;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n @media (hover: hover) {\n .pager > :deep(a:hover) {\n color: var(--ink);\n }\n }\n\n /* a phone frame has no room for a byline beside the mark */\n @media (max-width: 700px) {\n .theater-built {\n display: none;\n }\n }\n\n @media (max-width: 720px) {\n /* a text link is a 17px-tall tap target; the padding is the target */\n .demo-nav {\n margin-bottom: -8px;\n }\n\n .back {\n padding: 8px 0;\n }\n\n .next-demo {\n gap: 6px;\n }\n\n .next-demo b {\n max-width: 42vw;\n }\n\n .pager > :deep(a) {\n max-width: 46%;\n padding: 8px 0;\n }\n\n .stage-wrap {\n min-height: min(74vh, 620px);\n border-radius: 16px;\n }\n }\n </style>\n </template>\n}\n";
352
352
  //#endregion
353
353
  //#region ../choreo-gallery/realm/shell/demo-stage.gts?raw
354
354
  var demo_stage_default = "import Component from '@glimmer/component';\n\nimport type { GalleryDemo, StageSignature } from '../demo';\nimport { labSlug } from '../lib/lab';\n\ninterface Signature {\n Args: StageSignature['Args'] & { demo: GalleryDemo };\n}\n\n/**\n * A demo's live stage: the component its card class supplies as `stage`, or\n * a placeholder while the demo has no stage of its own yet. It carries the\n * demo's lab slug, which is how a lab finds every stage showing its demo to\n * retime the motion running there; it draws no box of its own.\n */\nexport class DemoStage extends Component<Signature> {\n get Stage() {\n return (this.args.demo.constructor as typeof GalleryDemo).stage;\n }\n\n get labSlug() {\n return labSlug(this.args.demo);\n }\n\n <template>\n <div class='demo-stage' data-demo-tuning={{this.labSlug}}>\n {{#if this.Stage}}\n <this.Stage\n @face={{@face}}\n @filmLink={{@filmLink}}\n @open={{@open}}\n @theater={{@theater}}\n />\n {{else}}\n <div class='stage-pending' data-stage-pending>\n <span>Stage coming soon</span>\n </div>\n {{/if}}\n </div>\n <style scoped>\n .demo-stage {\n display: contents;\n }\n\n .stage-pending {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n padding: 16px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n </style>\n </template>\n}\n";
@@ -396,7 +396,7 @@ var widget_room_default$2 = "import type { Camera3DState } from '@cardstack/chor
396
396
  var build_order_default = "import type { ChoreoRun, Query } from '@cardstack/choreo';\nimport { after, at, Choreo } from '@cardstack/choreo';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, motionValue, styles } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport {\n type Build,\n type Delivery,\n type EffectName,\n EFFECTS,\n effectsFor,\n partOf,\n type Relation,\n} from '../lib/builds';\nimport { observeStage } from '../lib/onstage';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneSeconds } from '../lib/tuning';\nimport BuildOrderNotes from '../notes/build-order';\nimport { BEAD, HEAD, ORBIT, TAIL, TIP } from '../shell/choreo-mark';\n\n/* ── the score, as it opens ──────────────────────────────────────────────── */\n\n/**\n * Nine builds, and not one absolute time among them.\n *\n * Read it the way you would read it aloud: the plate slides in, the comet's\n * tail draws with it, the head draws after the tail, the inner orbit sets off\n * WITH the head — two timelines now running in phase — the outer arrival\n * lands its bead, the inner one catches fire, then the wordmark comes after,\n * character by character, with the rule drawing under it and the tagline\n * last. The template below says exactly that, in the library's words: each\n * row becomes a named `<c.Tween>`, `with` is `{{at 'b3'}}`, `after` is\n * `{{after 'b3'}}`, and every start time in the transport is read back from\n * the run the compiler resolved — never computed here.\n */\nconst OPENING: Build[] = [\n {\n by: 'all',\n delay: 0,\n effect: 'move',\n ms: 620,\n part: 'plate',\n start: 'with',\n },\n {\n by: 'all',\n delay: 140,\n effect: 'draw',\n ms: 520,\n part: 'tail',\n start: 'with',\n },\n {\n by: 'all',\n delay: 0,\n effect: 'draw',\n ms: 520,\n part: 'head',\n start: 'after',\n },\n {\n by: 'all',\n delay: 200,\n effect: 'draw',\n ms: 560,\n part: 'orbit',\n start: 'with',\n },\n {\n by: 'all',\n delay: 320,\n effect: 'pop',\n ms: 380,\n part: 'bead',\n start: 'with',\n },\n {\n by: 'all',\n delay: 240,\n effect: 'draw',\n ms: 240,\n part: 'tip',\n start: 'with',\n },\n {\n by: 'character',\n delay: 60,\n effect: 'drift',\n ms: 760,\n part: 'word',\n start: 'after',\n },\n {\n by: 'all',\n delay: 240,\n effect: 'draw',\n ms: 520,\n part: 'rule',\n start: 'with',\n },\n {\n by: 'word',\n delay: 20,\n effect: 'soften',\n ms: 620,\n part: 'tag',\n start: 'after',\n },\n];\n\n/** how long the finished logo is held before the loop comes round again —\n a `<c.Wait>` on the timeline, so the hold is scrubbable like the rest */\nconst HOLD = 0.9;\n\n/** the stepper's grain, in ms — 100 so a 1-decimal readout moves every click */\nconst STEP = 100;\n\n/* ── the score's words, as template helpers ──────────────────────────────── */\n\n/** builds are named by row: the inspector's \"Build 3\" is the anchor `b3` */\nconst nameOf = (index: number) => `b${index + 1}`;\n\n/**\n * Two words and a reference, resolved by the compiler: `with` anchors on the\n * previous build's START, `after` on its end. Build 1 has no build above it,\n * so its start is the run's own zero — Keynote's \"On Click\" — and it is the\n * one step left in the sequence's natural flow.\n */\nconst anchorOf = (build: Build, index: number) =>\n index === 0\n ? undefined\n : build.start === 'with'\n ? at(nameOf(index - 1))\n : after(nameOf(index - 1));\n\nconst secondsOf = (ms: number) => ms / 1000;\n\n/** the inspector's 'all' is the library's default: the sprite is the unit */\nconst byOf = (by: Delivery) => (by === 'all' ? undefined : by);\n\nconst isFx = (build: Build, effect: EffectName) => build.effect === effect;\n\n/**\n * The score names the STANDING scene: kept sprites, by id. Not `c.id` —\n * an untyped id query also matches a REMOVED sprite, and when this whole\n * demo sits inside a leaving gallery card, that would conscript every part\n * into the full four-second opening while its Presence waits on the region.\n * A dying card's parts are none of this score's business: with no cue\n * naming them, the region hands them back to Presence immediately.\n */\nconst standing = (id: string): Query => ({ id, type: 'kept' });\n\n/* ── the demo ────────────────────────────────────────────────────────────── */\n\n/**\n * A logo on the left, the build order that makes it on the right.\n *\n * This demo used to carry its own scheduler — a `schedule()` that resolved\n * with/after into start times, a `windowOf`/`slotOf` pair that divided a text\n * build among its glyphs, and a `poseAt` sampled by its own rAF clock. All\n * deleted: the score is now written as a `<c.Sequence>` of named steps, the\n * glyphs are `@by='character'` delivery, and the transport holds `c.run` —\n * play, pause and the scrubber are `run.play()`, `run.pause()` and\n * `run.time = t` on the library's own clock.\n *\n * What the panel is here to show survives the move: the score is EDITABLE\n * while it runs. Change build 6 from After to With and every start time\n * downstream of it moves, because none of them was ever written down — the\n * edit re-renders the steps, the region replays the pass, and the run is put\n * back at the same t against the new schedule. The bars in the transport are\n * read from `run.cues` — the compiler's resolved answer, not a second copy.\n */\nexport class BuildOrder extends Component {\n @tracked builds: Build[] = OPENING.map((build) => ({ ...build }));\n @tracked loop = true;\n /** which row the inspector is editing */\n @tracked pick = 0;\n @tracked playing = true;\n /** bumped to replay: any change inside the region re-runs the pass */\n @tracked take = 0;\n /** each build's resolved window as fractions of the run, from `run.cues` */\n @tracked resolved: { at: number; end: number }[] = [];\n\n /**\n * The playhead and the run's length, in seconds — NOT tracked, and that is\n * load-bearing: the region's render detector re-runs on EVERY render, so a\n * tracked value written at 60fps would replay the pass at 60fps, cancelling\n * the run it is trying to watch. The clock readout and the range input are\n * written imperatively in `tick` instead.\n */\n private t = 0;\n private runtime = 0;\n\n /** the region's yielded context, grabbed by a modifier — `this.c.run` is the transport's whole subject */\n private c?: { run: ChoreoRun | null };\n /** the run the transport last adopted, so a new pass is noticed once */\n private seen: ChoreoRun | null = null;\n /** the run whose natural end already triggered the loop's next take */\n private doneOf: ChoreoRun | null = null;\n /** where to put the playhead when the next run arrives (an edit replays) */\n private reseek: number | null = null;\n /** true once a hand has stopped the run, after which it stays stopped */\n private held = false;\n /** whether the stage is being looked at — the transport's other gate */\n private onstage = false;\n\n /** the transport's own value, painted on the same frame as the scene */\n private headX = motionValue(0);\n\n private stage?: HTMLElement;\n private railW = 0;\n private watcher?: ResizeObserver;\n private viewport?: ReturnType<typeof observeStage>;\n private raf = 0;\n /** the readouts `tick` writes by hand, found once */\n private clockEl?: HTMLElement | null;\n private rangeEl?: HTMLInputElement | null;\n\n willDestroy() {\n super.willDestroy();\n cancelAnimationFrame(this.raf);\n this.watcher?.disconnect();\n this.viewport?.disconnect();\n }\n\n /* — wiring — */\n\n /** hold the region's context; the modifier's element is just a hook */\n wire = modifier((_el: Element, [c]: [{ run: ChoreoRun | null }]) => {\n this.c = c;\n });\n\n register = modifier((el: HTMLElement) => {\n this.stage = el;\n // a debug/test handle: the integration test drives the transport directly\n (el as HTMLElement & { buildOrder?: BuildOrder }).buildOrder = this;\n this.watcher = new ResizeObserver(() => {\n this.railW =\n this.stage?.querySelector<HTMLElement>('.bo-track')?.clientWidth ?? 0;\n });\n this.watcher.observe(el);\n // A logo animation that has to be started is a logo animation nobody\n // sees, so this one runs itself — but only while it is being looked at.\n // Twenty-six demos live in the gallery, and the frames belong to whichever\n // of them is on screen. Off screen the run pauses where it was; back on\n // screen it carries on, unless a hand has paused it since.\n this.viewport = observeStage(\n el,\n (visible) => {\n this.onstage = visible;\n if (this.onstage) {\n this.raf ||= requestAnimationFrame(this.tick);\n if (this.seen === null && this.take === 0) {\n // the opening's first take: the region deliberately plays\n // nothing on its very first render (page loads do not animate),\n // so being seen is what starts the show\n this.take++;\n }\n // playing is `tick`'s business now, every frame — see the\n // assertion there. Deciding it here once, on the edge, is what\n // left this demo dark: a run can be born while the stage is off\n // screen, get paused by the branch below, and then meet a\n // scroll-in that takes the first-take branch instead of the\n // resume — the logo sits at t≈0 forever, which is blank.\n } else {\n cancelAnimationFrame(this.raf);\n this.raf = 0;\n this.c?.run?.pause();\n }\n },\n { threshold: 0.35 },\n );\n return () => {\n this.watcher?.disconnect();\n this.viewport?.disconnect();\n this.watcher = undefined;\n this.viewport = undefined;\n this.stage = undefined;\n };\n });\n\n /* — the transport's frame: a reader, not a clock — */\n\n /**\n * The run owns the time; this loop only looks at it. A new run appears\n * whenever the region replays a pass — an edit, or the loop's next take —\n * and is adopted here: its resolved cues become the bars, and if the pass\n * was an edit, the playhead is put back at the t it was parked on, so\n * retiming build 2 while parked at 1.4s shows you what 1.4s now looks like.\n */\n private tick = () => {\n const run = this.c?.run ?? null;\n if (run && run !== this.seen) {\n this.seen = run;\n this.adopt(run);\n }\n if (run) {\n // The transport is ASSERTED, not merely set. Every other way a run\n // can end up paused — born off screen, kept across a pass that had\n // stopped it, quieted by a crossing overhead — is invisible from\n // here, and this demo is the gallery's last tile: a wordmark drawing\n // itself, which nobody watches start. `play()` is a no-op on a run\n // already playing (and on one parked at a gate), so a per-frame call\n // is an invariant, not work.\n if (this.onstage && this.playing && !this.held) {\n run.play();\n }\n const t = run.time;\n this.t = t;\n this.paint();\n if (run.isDone() && this.doneOf !== run && !this.held) {\n this.doneOf = run;\n if (this.loop) {\n this.take++;\n } else {\n // the run is over rather than interrupted, so scrolling past and\n // back must not quietly start it again\n this.playing = false;\n this.held = true;\n }\n }\n }\n this.raf = requestAnimationFrame(this.tick);\n };\n\n /** the clock, the range and the playhead, written by hand — see `t` */\n private paint() {\n this.headX.jump(\n this.runtime > 0 ? (this.t / this.runtime) * this.railW : 0,\n );\n this.clockEl ??= this.stage?.querySelector<HTMLElement>('.bo-clock');\n this.rangeEl ??= this.stage?.querySelector<HTMLInputElement>('.bo-range');\n const text = `${this.t.toFixed(2)} / ${this.runtime.toFixed(2)}s`;\n if (this.clockEl && this.clockEl.textContent !== text) {\n this.clockEl.textContent = text;\n }\n // While a hand is ON the thumb, the range belongs to the hand: a\n // programmatic value write during an active drag detaches WebKit's\n // pointer tracking outright — the scrubber reads as dead in Safari.\n if (this.rangeEl && !this.scrubbing) {\n const max = String(this.runtime);\n if (this.rangeEl.max !== max) {\n this.rangeEl.max = max;\n }\n // a parked thumb is corrected, never nudged: the run's clock returns\n // a float round-trip of what was set, and rewriting \"1.79\" as\n // \"1.7900000000000003\" would clobber a keyboard step mid-press\n if (\n this.playing ||\n Math.abs(Number(this.rangeEl.value) - this.t) > 0.02\n ) {\n this.rangeEl.value = String(this.t);\n }\n }\n }\n\n private adopt(run: ChoreoRun) {\n this.runtime = run.duration;\n // Continuity is the rule, not the exception: any pass replaces the run —\n // an edit, the loop's next take, an unrelated render — and the new run\n // resumes where the old one stood. Only a run that had actually finished\n // starts its successor from zero (the loop coming round).\n const target = this.reseek ?? this.t;\n this.reseek = null;\n if (target > 0.005 && target < run.duration - 0.005) {\n run.time = target;\n }\n if (!this.playing || this.held) {\n run.pause();\n }\n // the resolved schedule, read back: one bar per build, as fractions.\n // `wait` (the hold) still counts toward the total they are taken against.\n // Equality-guarded: `resolved` is tracked, a tracked write re-renders,\n // a render replays the pass — an unguarded write here would spin.\n const total = Math.max(\n 1,\n ...run.cues.map((cue) => cue.start + cue.duration),\n );\n const next = run.cues\n .filter((cue) => cue.kind === 'tween')\n .map((cue) => ({\n at: cue.start / total,\n end: (cue.start + cue.duration) / total,\n }));\n if (JSON.stringify(next) !== JSON.stringify(this.resolved)) {\n this.resolved = next;\n }\n }\n\n /* — transport — */\n\n toggle = () => {\n const run = this.c?.run;\n if (this.playing) {\n this.playing = false;\n this.held = true;\n run?.pause();\n return;\n }\n this.held = false;\n this.playing = true;\n if (!run || run.isDone() || this.t >= this.runtime - 0.001) {\n this.doneOf = run ?? null;\n this.take++;\n return;\n }\n run.play();\n };\n\n /**\n * The hand lands BEFORE it moves: pointerdown claims the clock — pause\n * now, or the still-playing run crawls the thumb out from under the\n * finger before the first `input` ever fires. `scrubbing` also stops\n * `paint` from touching the range for the whole drag (see paint).\n */\n grab = () => {\n this.scrubbing = true;\n this.held = true;\n this.playing = false;\n this.c?.run?.pause();\n };\n\n release = () => {\n this.scrubbing = false;\n };\n\n private scrubbing = false;\n\n /**\n * Scrubbing pauses — not because it has to, but because a hand on the\n * playhead has claimed the clock. `run.time` is settable in either\n * direction and a scrubbed frame is a computed still; the only question is\n * who supplies the number, and two suppliers at once is a tug of war.\n */\n scrub = (event: Event) => {\n const run = this.c?.run;\n if (!run) {\n return;\n }\n this.held = true;\n this.playing = false;\n run.pause();\n const t = Number((event.target as HTMLInputElement).value);\n run.time = t;\n this.t = t;\n this.paint();\n };\n\n rewind = () => {\n const run = this.c?.run;\n this.playing = false;\n run?.pause();\n if (run) {\n run.time = 0;\n }\n this.t = 0;\n this.paint();\n };\n\n toggleLoop = () => {\n this.loop = !this.loop;\n };\n\n /* — the inspector — */\n\n get build(): Build {\n return this.builds[this.pick]!;\n }\n\n get part() {\n return partOf(this.build.part);\n }\n\n get isText() {\n return this.part.kind === 'text';\n }\n\n select = (index: number) => {\n this.pick = index;\n };\n\n /**\n * Change one build, and let the rest of the timeline fall out again.\n *\n * The score is replaced rather than mutated so the steps re-render, which\n * replays the pass — the region compiles a fresh run against the new\n * schedule. `reseek` asks `adopt` to put the new run back at the SAME t,\n * which is what makes an edit feel like editing rather than restarting.\n */\n private edit(patch: Partial<Build>) {\n this.builds = this.builds.map((build, i) =>\n i === this.pick ? { ...build, ...patch } : build,\n );\n this.reseek = this.t;\n }\n\n setEffect = (event: Event) => {\n this.edit({\n effect: (event.target as HTMLSelectElement).value as EffectName,\n });\n };\n\n setStart = (event: Event) => {\n this.edit({ start: (event.target as HTMLSelectElement).value as Relation });\n };\n\n setBy = (event: Event) => {\n this.edit({ by: (event.target as HTMLSelectElement).value as Delivery });\n };\n\n nudgeDelay = (by: number) => {\n this.edit({ delay: Math.max(0, this.build.delay + by * STEP) });\n };\n\n nudgeMs = (by: number) => {\n this.edit({ ms: Math.max(STEP * 2, this.build.ms + by * STEP) });\n };\n\n /* — readouts — */\n\n /** the last build's name, which the hold waits behind */\n get holdAt() {\n return after(nameOf(this.builds.length - 1));\n }\n\n /**\n * The sync marks: one faint vertical line per relation, drawn at the moment\n * a build's start was derived FROM — the previous build's start for `with`,\n * its end for `after`. The line is the anchor; the gap between the line and\n * the bar to its right is the delay, made visible.\n */\n get links() {\n return this.builds.slice(1).map((build, i) => {\n const prev = this.resolved[i];\n const frac = prev ? (build.start === 'with' ? prev.at : prev.end) : 0;\n return {\n index: i,\n // lane geometry mirrored from .bo-track: 3px pad, 3px lane, 2px gap —\n // the line runs from the previous lane's centre to this one's\n style: htmlSafe(\n `left:${(frac * 100).toFixed(3)}%;` +\n `top:${(4.5 + i * 5).toFixed(1)}px;height:5px`,\n ),\n };\n });\n }\n\n get lanes() {\n return this.builds.map((_build, index) => {\n const span = this.resolved[index];\n return {\n index,\n on: index === this.pick,\n style: htmlSafe(\n span\n ? `left:${(span.at * 100).toFixed(3)}%;` +\n `width:${((span.end - span.at) * 100).toFixed(3)}%`\n : 'left:0;width:0',\n ),\n };\n });\n }\n\n get list() {\n return this.builds.map((build, index) => ({\n effect: EFFECTS[build.effect]!.label,\n index,\n label: partOf(build.part).label,\n no: index + 1,\n on: index === this.pick,\n }));\n }\n\n get effectOptions() {\n return effectsFor(this.part.kind).map((name) => ({\n label: EFFECTS[name]!.label,\n name,\n on: name === this.build.effect,\n }));\n }\n\n get startOptions() {\n const above = this.pick;\n return [\n {\n label: `With Build ${above}`,\n name: 'with',\n on: this.build.start === 'with',\n },\n {\n label: `After Build ${above}`,\n name: 'after',\n on: this.build.start === 'after',\n },\n ];\n }\n\n get deliveryOptions() {\n return [\n { label: 'All at Once', name: 'all', on: this.build.by === 'all' },\n { label: 'By Word', name: 'word', on: this.build.by === 'word' },\n {\n label: 'By Character',\n name: 'character',\n on: this.build.by === 'character',\n },\n ];\n }\n\n get first() {\n return this.pick === 0;\n }\n\n get delayText() {\n return `${(this.build.delay / 1000).toFixed(1)} s`;\n }\n\n get msText() {\n return `${(this.build.ms / 1000).toFixed(1)} s`;\n }\n\n /** the nav mark's geometry, worn here at stage size */\n tailD = TAIL;\n headD = HEAD;\n orbitD = ORBIT;\n tipD = TIP;\n bead = BEAD;\n\n <template>\n <div\n class='ex bo-stage no-select'\n {{this.register}}\n {{on 'selectstart' preventSelect}}\n >\n <div class='bo-split'>\n\n {{! ── the logo: the region is the canvas ─────────────────────── }}\n <div class='bo-canvas'>\n <Choreo class='bo-logo' as |c|>\n {{! `data-take` is the replay: bumping it re-renders the region,\n and a pass whose changeset is all-kept still runs its\n timeline — the loop's next take, with no imperative restart.\n The box that moves: a plate the mark is built on. }}\n <div\n class='bo-plate'\n data-part='plate'\n data-take='{{this.take}}'\n {{motion id='plate'}}\n {{this.wire c}}\n ></div>\n\n <div class='bo-art'>\n {{! the mark itself, from the same exported geometry the top\n bar renders — two strokes that draw, one bead that pops }}\n <svg class='bo-mark' viewBox='0 0 24 24' aria-hidden='true'>\n <path\n class='bo-tail'\n d={{this.tailD}}\n data-part='tail'\n {{motion id='tail'}}\n />\n <path\n class='bo-head'\n d={{this.headD}}\n data-part='head'\n {{motion id='head'}}\n />\n <circle\n class='bo-bead'\n cx='{{this.bead.cx}}'\n cy='{{this.bead.cy}}'\n r='{{this.bead.r}}'\n data-part='bead'\n {{motion id='bead'}}\n />\n <path\n class='bo-orbit'\n d={{this.orbitD}}\n data-part='orbit'\n {{motion id='orbit'}}\n />\n <path\n class='bo-tip'\n d={{this.tipD}}\n data-part='tip'\n {{motion id='tip'}}\n />\n </svg>\n\n {{! text, whole: `@by` splits it at delivery time and puts\n Glimmer's own text nodes back after — no glyph spans here }}\n <p class='bo-word' data-part='word' {{motion id='word'}}>\n Choreo</p>\n\n <svg\n class='bo-rule-box'\n viewBox='0 0 220 3'\n preserveAspectRatio='none'\n aria-hidden='true'\n >\n <path\n class='bo-rule'\n d='M1.5 1.5H218.5'\n vector-effect='non-scaling-stroke'\n data-part='rule'\n {{motion id='rule'}}\n />\n </svg>\n\n <p class='bo-tag' data-part='tag' {{motion id='tag'}}>\n MOTION. CHOREOGRAPHED.</p>\n </div>\n\n {{! ── the score, verbatim ──────────────────────────────────\n One named step per build. The template's branches are the\n effect column: an effect is nothing but keyframe values and\n an easing on a `<c.Tween>` — Pop is a scale through backOut,\n and a build-in's leading opacity frames are Keynote's\n \"builds in\", pinned hidden from the run's start until its\n window opens. }}\n <c.Sequence>\n {{#each this.builds key='@index' as |build i|}}\n {{#if (isFx build 'dissolve')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeOut'\n @opacity={{array 0 1}}\n />\n {{else if (isFx build 'draw')}}\n {{! opacity leads: a zero-length dash with round linecaps\n still paints its caps, so the stroke is withheld until\n there is line to show. @pathSpacing pinned at 2 keeps\n the dash pattern's second dash a whole path-length away\n from the terminus at any drawn length. }}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeInOut'\n @pathLength={{array 0 1}}\n @pathSpacing={{array 2 2}}\n @opacity={{array 0 1 1 1 1}}\n />\n {{else if (isFx build 'drift')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeOut'\n @opacity={{array 0 1 1}}\n @scale={{array 0.78 1}}\n @y={{array 18 0}}\n />\n {{else if (isFx build 'move')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeOut'\n @opacity={{array 0 1 1 1}}\n @x={{array -38 0}}\n />\n {{else if (isFx build 'pop')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='backOut'\n @opacity={{array 0 1 1 1}}\n @scale={{array 0 1}}\n />\n {{else if (isFx build 'soften')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeOut'\n @opacity={{array 0 1 1}}\n @scale={{array 1.06 1}}\n @filter={{array 'blur(10px)' 'blur(0px)'}}\n />\n {{else if (isFx build 'spin')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='backOut'\n @opacity={{array 0 1 1 1}}\n @rotate={{array -150 0}}\n @scale={{array 0.4 1}}\n />\n {{else if (isFx build 'wipe')}}\n <c.Tween\n @name={{nameOf i}}\n @at={{anchorOf build i}}\n @delay={{secondsOf build.delay}}\n @of={{standing build.part}}\n @by={{byOf build.by}}\n @duration={{tuneSeconds\n 'build-order'\n (secondsOf build.ms)\n 'secondsOf build.ms duration'\n }}\n @ease='easeInOut'\n @clipPath={{array\n 'inset(0 100% 0 0 round 20px)'\n 'inset(0 0% 0 0 round 20px)'\n }}\n />\n {{/if}}\n {{/each}}\n {{! the rest at the end of the take, on the timeline itself —\n scrubbable, and counted in the run's length }}\n <c.Wait\n @of={{standing 'plate'}}\n @at={{this.holdAt}}\n @duration={{tuneSeconds 'build-order' HOLD 'HOLD duration'}}\n />\n </c.Sequence>\n </Choreo>\n </div>\n\n {{! ── the inspector ──────────────────────────────────────────── }}\n <div class='bo-panel'>\n <p class='bo-panel-head'>Build Order</p>\n\n <ol class='bo-list'>\n {{#each this.list key='index' as |row|}}\n <li>\n <button\n type='button'\n class={{if row.on 'bo-row is-on' 'bo-row'}}\n {{on 'click' (fn this.select row.index)}}\n >\n <span class='bo-no'>{{row.no}}</span>\n <span class='bo-name'>{{row.label}}</span>\n <span class='bo-fx'>{{row.effect}}</span>\n </button>\n </li>\n {{/each}}\n </ol>\n\n <div class='bo-form'>\n <label class='bo-field is-wide'>\n <span>Effect</span>\n <select {{on 'change' this.setEffect}}>\n {{#each this.effectOptions key='name' as |opt|}}\n <option\n value={{opt.name}}\n selected={{opt.on}}\n >{{opt.label}}</option>\n {{/each}}\n </select>\n </label>\n\n <label class='bo-field is-wide'>\n <span>Start</span>\n {{#if this.first}}\n {{! build 1 has no build above it, so Keynote's only offer is\n the run's own zero — and so is ours }}\n <select disabled>\n <option>On Click</option>\n </select>\n {{else}}\n <select {{on 'change' this.setStart}}>\n {{#each this.startOptions key='name' as |opt|}}\n <option\n value={{opt.name}}\n selected={{opt.on}}\n >{{opt.label}}</option>\n {{/each}}\n </select>\n {{/if}}\n </label>\n\n <div class='bo-field'>\n <span>Delay</span>\n <div class='bo-step'>\n <b>{{this.delayText}}</b>\n <span class='bo-arrows'>\n <button\n type='button'\n aria-label='More delay'\n {{on 'click' (fn this.nudgeDelay 1)}}\n >▴</button>\n <button\n type='button'\n aria-label='Less delay'\n {{on 'click' (fn this.nudgeDelay -1)}}\n >▾</button>\n </span>\n </div>\n </div>\n\n <div class='bo-field'>\n <span>Duration</span>\n <div class='bo-step'>\n <b>{{this.msText}}</b>\n <span class='bo-arrows'>\n <button\n type='button'\n aria-label='Longer'\n {{on 'click' (fn this.nudgeMs 1)}}\n >▴</button>\n <button\n type='button'\n aria-label='Shorter'\n {{on 'click' (fn this.nudgeMs -1)}}\n >▾</button>\n </span>\n </div>\n </div>\n\n {{#if this.isText}}\n <label class='bo-field is-wide'>\n <span>Delivery</span>\n <select {{on 'change' this.setBy}}>\n {{#each this.deliveryOptions key='name' as |opt|}}\n <option\n value={{opt.name}}\n selected={{opt.on}}\n >{{opt.label}}</option>\n {{/each}}\n </select>\n </label>\n {{/if}}\n </div>\n </div>\n </div>\n\n {{! ── the transport ────────────────────────────────────────────── }}\n <div class='bo-transport'>\n <div class='bo-track'>\n {{! one lane per build: the resolved schedule, drawn. It is not a\n second copy of the score — it is read back from `run.cues`. }}\n {{#each this.lanes key='index' as |lane|}}\n <span class='bo-lane'>\n <i\n class={{if lane.on 'bo-bar is-on' 'bo-bar'}}\n style={{lane.style}}\n ></i>\n </span>\n {{/each}}\n {{! the with/after relations, drawn: each line hangs from the moment\n the build below it is timed against }}\n {{#each this.links key='index' as |link|}}\n <i class='bo-sync' style={{link.style}}></i>\n {{/each}}\n <span\n class='bo-playhead'\n {{motion style=(styles x=this.headX)}}\n ></span>\n {{! a real range input, transparent over the drawing: keyboard scrub\n and a11y for free }}\n {{! max and value are written by `paint` each frame — a tracked\n binding here would re-render at 60fps, and every render replays\n the region's pass }}\n <input\n type='range'\n class='bo-range'\n min='0'\n max='0'\n step='0.01'\n value='0'\n aria-label='Playhead'\n {{on 'pointerdown' this.grab}}\n {{on 'pointerup' this.release}}\n {{on 'pointercancel' this.release}}\n {{on 'input' this.scrub}}\n />\n </div>\n\n <div class='bo-controls'>\n <button\n type='button'\n class='bo-play'\n aria-label={{if this.playing 'Pause' 'Play'}}\n {{on 'click' this.toggle}}\n >\n {{#if this.playing}}\n {{! lucide \"pause\" }}\n <svg class='tp-icon' viewBox='0 0 24 24' aria-hidden='true'>\n <rect x='14' y='3' width='5' height='18' rx='1' />\n <rect x='5' y='3' width='5' height='18' rx='1' />\n </svg>\n {{else}}\n {{! lucide \"play\" }}\n <svg class='tp-icon' viewBox='0 0 24 24' aria-hidden='true'>\n <path\n d='M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z'\n />\n </svg>\n {{/if}}\n </button>\n <span class='bo-clock'>0.00 / 0.00s</span>\n <span class='bo-spacer'></span>\n <button\n type='button'\n class={{if this.loop 'bo-chip is-on' 'bo-chip'}}\n aria-pressed='{{this.loop}}'\n {{on 'click' this.toggleLoop}}\n >Loop</button>\n <button\n type='button'\n class='bo-chip'\n {{on 'click' this.rewind}}\n >Reset</button>\n </div>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n /* Transport glyphs, drawn rather than typed.\n ▶ and ❚❚ are characters, and a character is at the mercy of whatever font\n resolves it — on iOS they picked up the emoji face and rendered as colour\n emoji next to a monospace clock. These are Lucide (lucide.dev, ISC), taken\n as published, which is also why they are stroked and not filled. */\n\n .tp-icon {\n width: 15px;\n height: 15px;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n /* Build Order — a logo, and the score that assembles it -------------------- */\n\n .bo-stage {\n /* the transport lives at the bottom of the stage, so the scene centres in\n what is left rather than under it */\n padding-bottom: 96px;\n cursor: default;\n }\n\n .bo-split {\n display: grid;\n grid-template-columns: minmax(0, 1fr) minmax(180px, 224px);\n gap: 16px;\n align-items: center;\n align-self: stretch;\n justify-self: stretch;\n width: 100%;\n max-width: 640px;\n min-height: 0;\n margin: 0 auto;\n padding-block: 12px;\n }\n\n /* the logo ---------------------------------------------------------------- */\n\n .bo-canvas {\n display: grid;\n place-items: center;\n min-width: 0;\n }\n\n .bo-logo {\n position: relative;\n display: grid;\n place-items: center;\n width: min(100%, 258px);\n padding: 20px 16px 18px;\n }\n\n /* the box that moves. A plate rather than a shadow, because Move In has to\n have something with edges to bring on. */\n\n .bo-plate {\n position: absolute;\n inset: 0;\n border: 1px solid var(--line-strong);\n border-radius: 20px;\n background: linear-gradient(180deg, var(--bg-spot), var(--bg-well));\n box-shadow: 0 20px 46px\n rgba(var(--shadow-rgb), calc(0.5 * var(--shadow-a)));\n }\n\n .bo-art {\n position: relative;\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 9px;\n width: 100%;\n }\n\n /* overflow is visible on purpose: Pop overshoots past 1 and Spin swings the\n chevrons outside their own boxes on the way in */\n\n .bo-mark {\n display: block;\n width: min(104px, 27cqh);\n height: auto;\n aspect-ratio: 1;\n overflow: visible;\n }\n\n /* The comet: the measurement line, arriving. The faintness of the tail is in\n the STROKE COLOR, not `opacity` — the cell's motion values write an inline\n opacity every frame, and inline beats stylesheet. */\n\n .bo-tail {\n fill: none;\n /* ember, not copper — see choreo-mark.gts, same fix for the same reason */\n stroke: rgba(255, 59, 31, 0.35);\n stroke-width: 2.2;\n stroke-linecap: round;\n }\n\n .bo-head {\n fill: none;\n /* ember read too red for the present-tense line — see choreo-mark.gts */\n stroke: var(--ember-hot);\n stroke-width: 2.2;\n stroke-linecap: round;\n }\n\n /* the second timeline: concentric, so the two can never cross */\n\n .bo-orbit {\n fill: none;\n stroke: rgba(139, 150, 156, 0.75);\n stroke-width: 1.6;\n stroke-linecap: round;\n }\n\n /* the inner NOW: not a bead but the line itself, burning — the last 85° of\n the sweep in the hot color, rounded caps */\n\n .bo-tip {\n fill: none;\n stroke: var(--ember-hot);\n stroke-width: 1.6;\n stroke-linecap: round;\n }\n\n /* the outer NOW: the one filled thing on the mark. fill-box, because without\n it a transform on an SVG child is measured against the viewport's origin\n and Pop would fling it off the arc. */\n\n .bo-bead {\n fill: var(--ember);\n transform-box: fill-box;\n transform-origin: 50% 50%;\n }\n\n /* text: whole elements. `@by` splits them into stand-in spans only for the\n window of their build and puts Glimmer's own text nodes back after — so\n there is nothing here but type; the spans the library makes are inline-block\n already, and they inherit the transform-origin below so a glyph scales from\n its baseline rather than its centre. */\n\n .bo-word,\n .bo-tag {\n display: block;\n text-align: center;\n margin: 0;\n }\n\n .bo-word > span,\n .bo-tag > span {\n transform-origin: 50% 80%;\n }\n\n .bo-word {\n font-family: var(--font-display);\n font-size: clamp(16px, 6.4cqh, 26px);\n font-weight: 800;\n letter-spacing: -0.02em;\n line-height: 1;\n color: var(--ink);\n }\n\n .bo-tag {\n font-family: var(--font-mono);\n font-size: clamp(7px, 2.3cqh, 9px);\n letter-spacing: 0.2em;\n color: var(--ink-dim);\n }\n\n .bo-rule-box {\n display: block;\n width: min(100%, 186px);\n height: 3px;\n overflow: visible;\n }\n\n .bo-rule {\n fill: none;\n stroke: var(--ember);\n stroke-width: 2;\n stroke-linecap: round;\n }\n\n /* the inspector ----------------------------------------------------------- */\n\n .bo-panel {\n display: flex;\n flex-direction: column;\n min-width: 0;\n min-height: 0;\n max-height: 100%;\n border: 1px solid var(--line);\n border-radius: 14px;\n /* same device-chrome treatment as .ph-app — a neutral black veil read as\n flat grey once its card turned light */\n background: linear-gradient(180deg, var(--bg-spot), var(--bg-well));\n box-shadow: 0 18px 44px\n rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-a)));\n overflow: hidden;\n }\n\n .bo-panel-head {\n flex: none;\n margin: 0;\n padding: 8px 10px;\n border-bottom: 1px solid var(--line);\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .bo-list {\n flex: 1 1 auto;\n min-height: 44px;\n margin: 0;\n padding: 4px;\n list-style: none;\n overflow-y: auto;\n overscroll-behavior: contain;\n }\n\n .bo-row {\n display: grid;\n grid-template-columns: 12px minmax(0, 1fr) auto;\n gap: 7px;\n align-items: center;\n width: 100%;\n padding: 4px 6px;\n border: 0;\n border-radius: 6px;\n background: none;\n font: inherit;\n color: var(--ink-dim);\n text-align: left;\n cursor: pointer;\n }\n\n .bo-row.is-on {\n background: var(--ember);\n color: #fff;\n }\n\n .bo-no {\n font-family: var(--font-mono);\n font-size: 9px;\n color: var(--ink-faint);\n text-align: right;\n }\n\n .bo-name {\n overflow: hidden;\n font-size: 11px;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .bo-fx {\n font-family: var(--font-mono);\n font-size: 9px;\n color: var(--ink-faint);\n white-space: nowrap;\n }\n\n .bo-row.is-on .bo-no,\n .bo-row.is-on .bo-fx {\n color: rgba(255, 255, 255, 0.78);\n }\n\n .bo-form {\n flex: none;\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 7px 8px;\n padding: 9px 10px 10px;\n border-top: 1px solid var(--line);\n }\n\n .bo-field {\n display: flex;\n flex-direction: column;\n gap: 3px;\n min-width: 0;\n }\n\n .bo-field.is-wide {\n grid-column: 1 / -1;\n }\n\n .bo-field > span {\n font-family: var(--font-mono);\n font-size: 8px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .bo-field select {\n width: 100%;\n height: 22px;\n padding: 0 4px;\n border: 1px solid var(--line-strong);\n border-radius: 6px;\n background: var(--bg-well);\n font: inherit;\n font-size: 10px;\n color: var(--ink);\n cursor: pointer;\n }\n\n .bo-field select:disabled {\n color: var(--ink-faint);\n cursor: default;\n }\n\n .bo-step {\n display: flex;\n align-items: center;\n justify-content: space-between;\n height: 22px;\n padding: 0 2px 0 7px;\n border: 1px solid var(--line-strong);\n border-radius: 6px;\n background: var(--bg-well);\n }\n\n .bo-step b {\n font-family: var(--font-mono);\n font-size: 10px;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n color: var(--ink);\n }\n\n .bo-arrows {\n display: flex;\n flex: none;\n flex-direction: column;\n }\n\n .bo-arrows button {\n display: block;\n width: 15px;\n height: 9px;\n padding: 0;\n border: 0;\n background: none;\n color: var(--ink-dim);\n font-size: 7px;\n line-height: 9px;\n cursor: pointer;\n }\n\n @media (hover: hover) {\n .bo-arrows button:hover {\n color: var(--ember-hot);\n }\n }\n\n /* the same box as the scene above it: a timeline wider than the thing it is\n timing reads as chrome rather than as part of the stage */\n\n .bo-transport {\n position: absolute;\n right: 16px;\n bottom: 12px;\n left: 16px;\n z-index: 6;\n display: flex;\n flex-direction: column;\n gap: 7px;\n max-width: 640px;\n margin-inline: auto;\n }\n\n /* one lane per build, in build order. This is not a second drawing of the\n score — it is what schedule() returned, which is why editing Start moves it. */\n\n .bo-track {\n position: relative;\n display: flex;\n flex-direction: column;\n gap: 2px;\n padding: 3px 0;\n }\n\n .bo-lane {\n position: relative;\n display: block;\n height: 3px;\n border-radius: 2px;\n background: rgba(var(--ink-rgb), 0.07);\n }\n\n .bo-bar {\n position: absolute;\n top: 0;\n height: 100%;\n min-width: 2px;\n border-radius: 2px;\n background: var(--steel);\n opacity: 0.5;\n }\n\n .bo-bar.is-on {\n background: linear-gradient(90deg, var(--ember), var(--copper));\n opacity: 1;\n }\n\n /* a relation, drawn: `with` hangs from the previous bar's start, `after` from\n its end. Faint on purpose — it is scaffolding, not content. */\n\n .bo-sync {\n position: absolute;\n z-index: 1;\n width: 1px;\n background: rgba(var(--ink-rgb), 0.28);\n pointer-events: none;\n }\n\n /* moved by a motion value, so the head repaints without a render */\n\n .bo-playhead {\n position: absolute;\n top: 0;\n bottom: 0;\n left: 0;\n z-index: 2;\n width: 1px;\n /* ember, not white: white reads on the dark card and vanishes on the\n light one, and the head already wears an ember halo — one colour for\n the whole playhead, legible on either ground */\n background: var(--ember);\n box-shadow: 0 0 7px var(--glow);\n pointer-events: none;\n }\n\n .bo-playhead::after {\n content: '';\n position: absolute;\n top: -4px;\n left: -3.5px;\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background: var(--ember);\n box-shadow: 0 0 0 3px rgba(255, 59, 31, 0.26);\n }\n\n /* the real control, invisible over the drawing */\n\n .bo-range {\n /* A horizontal slider and a vertical scroll want the same gesture, and on a\n touch screen the scroller wins by default: the drag panned the page and\n the playhead never moved, so the timeline read as broken rather than as\n scrubbable. `none` hands the gesture to the control, which is the only\n thing on this row that a sideways drag could plausibly mean. */\n touch-action: none;\n\n position: absolute;\n inset: 0;\n z-index: 3;\n width: 100%;\n height: 100%;\n margin: 0;\n padding: 0;\n opacity: 0;\n cursor: ew-resize;\n -webkit-appearance: none;\n appearance: none;\n background: none;\n }\n\n .bo-controls {\n display: flex;\n align-items: center;\n gap: 8px;\n }\n\n .bo-play {\n display: grid;\n flex: none;\n place-items: center;\n width: 22px;\n height: 22px;\n border: 1px solid var(--line-strong);\n border-radius: 7px;\n background: var(--bg-well);\n font: inherit;\n font-size: 9px;\n color: var(--ink);\n cursor: pointer;\n }\n\n .bo-clock {\n font-family: var(--font-mono);\n font-size: 9px;\n font-variant-numeric: tabular-nums;\n color: var(--ink-dim);\n }\n\n .bo-spacer {\n flex: 1 1 auto;\n }\n\n .bo-chip {\n flex: none;\n height: 22px;\n padding: 0 9px;\n border: 1px solid var(--line-strong);\n border-radius: 7px;\n background: var(--bg-well);\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-dim);\n cursor: pointer;\n }\n\n .bo-chip.is-on {\n border-color: var(--ember);\n color: var(--ember-hot);\n }\n\n @container (min-width: 620px) {\n .bo-split,\n .bo-transport {\n max-width: 740px;\n }\n\n .bo-split {\n grid-template-columns: minmax(0, 1fr) 244px;\n gap: 24px;\n }\n\n .bo-logo {\n width: min(100%, 340px);\n padding: 30px 24px 26px;\n }\n\n .bo-art {\n gap: 12px;\n }\n\n .bo-mark {\n width: min(136px, 30cqh);\n }\n\n .bo-word {\n font-size: clamp(20px, 7cqh, 34px);\n }\n\n .bo-rule-box {\n width: min(100%, 240px);\n }\n\n .bo-tag {\n font-size: clamp(8px, 2.4cqh, 11px);\n }\n }\n\n @container (max-width: 470px) {\n .bo-split {\n grid-template-columns: minmax(0, 1fr) 162px;\n gap: 10px;\n }\n\n .bo-fx {\n display: none;\n }\n\n .bo-logo {\n padding: 16px 12px 14px;\n }\n }\n\n .choreo-site:not([data-theme='light']) .bo-plate {\n background: linear-gradient(180deg, #322c27, #2a2521);\n }\n\n .choreo-site:not([data-theme='light']) .bo-field select {\n background: #25211d;\n }\n\n .choreo-site:not([data-theme='light']) .bo-step {\n background: #25211d;\n }\n\n .choreo-site:not([data-theme='light']) .bo-play {\n background: rgba(0, 0, 0, 0.22);\n }\n\n .choreo-site:not([data-theme='light']) .bo-chip {\n background: rgba(0, 0, 0, 0.22);\n }\n\n .choreo-site:not([data-theme='light']) .bo-panel {\n background: rgba(0, 0, 0, 0.22);\n }\n </style>\n </template>\n}\n\nexport class BuildOrderDemo extends GalleryDemo {\n static stage = BuildOrder;\n static notes = BuildOrderNotes;\n}\n";
397
397
  //#endregion
398
398
  //#region ../choreo-gallery/realm/stages/camera.gts?raw
399
- var camera_default = "import type { ChoreoContext } from '@cardstack/choreo';\nimport { Choreo } from '@cardstack/choreo';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, spring } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneSpring } from '../lib/tuning';\nimport CameraNotes from '../notes/camera';\n\n/**\n * A photo library. The whole set sits on the glass at once — dive IN onto a\n * shot and the camera info develops: its number first, then, a beat later,\n * the caption, the exposure, and the verdict.\n *\n * The dive is `@fit`: the library computes the zoom AND the pan so the\n * photograph fills a set share of the glass, centred — from rest-layout\n * geometry, so a click that lands mid-flight on a different tile still\n * measures true. The grade panel never has to compete with it for room —\n * it's a callout beside the frame, not a card laid over the photo.\n */\nconst brackets = [\n {\n caption: 'Night shift',\n exposure: 'f/2.8 · 1/60 · ISO 800',\n no: '01',\n },\n {\n caption: 'Foundry glass',\n exposure: 'f/2 · 1/30 · ISO 1600',\n no: '02',\n },\n];\n\n/**\n * Twelve photographs, not twelve cuts of the same amber negative — and\n * not twelve colour swatches either. Each wash is layered like an\n * out-of-focus exposure: a bright bokeh bloom off-centre, a softer\n * counter-glow from another corner, a faint glint, over a dark ground.\n * The framing (where the light sits) varies shot to shot, the way a\n * bracket's frames wander; only the hue family stays per bracket row.\n */\nconst washes = [\n // 01 — night shift: a streetlamp high right, sodium haze low left\n `radial-gradient(150% 120% at 74% 10%, #a9c8f5d9 0%, #8eb8f000 74%),\n radial-gradient(110% 95% at 16% 82%, #3c5c8cb8 0%, #2a4a7800 82%),\n radial-gradient(60% 45% at 54% 44%, #ffffff1f 0%, #ffffff00 100%),\n linear-gradient(158deg, #2c4a74 0%, #101a26 100%)`,\n // 02 — the same street, warmer glass: shopfront glow left of frame\n `radial-gradient(140% 120% at 26% 18%, #ffc07ad4 0%, #ffc07a00 76%),\n radial-gradient(120% 100% at 80% 78%, #d4581caa 0%, #d4581c00 80%),\n radial-gradient(55% 45% at 58% 50%, #ffe6c41c 0%, #ffe6c400 100%),\n linear-gradient(148deg, #a03e14 0%, #301408 100%)`,\n // 03 — a neon sign bleeding through the top of the frame\n `radial-gradient(160% 110% at 60% 4%, #c4a0ffd4 0%, #c4a0ff00 72%),\n radial-gradient(100% 100% at 12% 70%, #6a36b3a8 0%, #5a2a9c00 80%),\n radial-gradient(55% 45% at 78% 54%, #ffffff17 0%, #ffffff00 100%),\n linear-gradient(164deg, #4a2384 0%, #1c1030 100%)`,\n // 04 — window light on water, the bloom sunk low\n `radial-gradient(145% 115% at 68% 86%, #6fd6c4cf 0%, #6fd6c400 76%),\n radial-gradient(110% 90% at 22% 16%, #2a7a78a8 0%, #1a5c5e00 82%),\n radial-gradient(60% 40% at 46% 52%, #c8f2e614 0%, #c8f2e600 100%),\n linear-gradient(152deg, #16494a 0%, #0c1c1e 100%)`,\n // 05 — tail lights, hot centre-right\n `radial-gradient(135% 115% at 66% 32%, #ff8a5ad4 0%, #ff8a5a00 72%),\n radial-gradient(100% 110% at 10% 88%, #c4271299 0%, #c4271200 78%),\n radial-gradient(55% 40% at 32% 22%, #ffd9b829 0%, #ffd9b800 100%),\n linear-gradient(160deg, #8a1e0e 0%, #220c08 100%)`,\n // 06 — overcast daylight, one soft silver source\n `radial-gradient(170% 130% at 38% 6%, #d0d6dacf 0%, #d0d6da00 78%),\n radial-gradient(110% 90% at 84% 80%, #6a737694 0%, #5c656800 82%),\n radial-gradient(65% 50% at 60% 46%, #f2f5f614 0%, #f2f5f600 100%),\n linear-gradient(170deg, #4c5457 0%, #1a1815 100%)`,\n // 07 — the foundry door cracked open, low left\n `radial-gradient(140% 120% at 20% 84%, #ff7a45d1 0%, #ff7a4500 74%),\n radial-gradient(110% 95% at 78% 20%, #c42712a3 0%, #c4271200 80%),\n radial-gradient(55% 45% at 48% 54%, #ffc79e1a 0%, #ffc79e00 100%),\n linear-gradient(160deg, #7e1c0c 0%, #2e100a 100%)`,\n // 08 — pour in frame: the melt blown out top left, sparks right\n `radial-gradient(145% 115% at 24% 12%, #ffb36ad6 0%, #ffb36a00 74%),\n radial-gradient(105% 110% at 84% 64%, #ff3b1f9e 0%, #ff3b1f00 76%),\n radial-gradient(50% 40% at 60% 32%, #fff1d633 0%, #fff1d600 100%),\n linear-gradient(145deg, #b2320f 0%, #4a1610 100%)`,\n // 09 — smoke and skylight, silver from above\n `radial-gradient(165% 120% at 56% 2%, #c5cdd0cf 0%, #c5cdd000 76%),\n radial-gradient(100% 100% at 14% 78%, #6a737691 0%, #5c656800 82%),\n radial-gradient(60% 45% at 70% 50%, #eef2f414 0%, #eef2f400 100%),\n linear-gradient(165deg, #474f52 0%, #1c1a16 100%)`,\n // 10 — blown-out furnace glass, nearly the whole frame\n `radial-gradient(155% 125% at 62% 28%, #fff4e8db 0%, #fff4e800 78%),\n radial-gradient(120% 100% at 14% 84%, #e4a35ab3 0%, #e4a35a00 80%),\n linear-gradient(150deg, #b57838 0%, #5c3a1c 100%)`,\n // 11 — arc light through violet gel, high left\n `radial-gradient(140% 115% at 22% 8%, #b08cffd4 0%, #b08cff00 74%),\n radial-gradient(110% 95% at 80% 74%, #5c2494ad 0%, #4a187800 80%),\n radial-gradient(55% 45% at 50% 48%, #ffffff1a 0%, #ffffff00 100%),\n linear-gradient(156deg, #3c1464 0%, #180c26 100%)`,\n // 12 — sodium on brass, acid bloom low right\n `radial-gradient(145% 115% at 78% 76%, #b6d15acf 0%, #b6d15a00 76%),\n radial-gradient(105% 90% at 18% 18%, #5a742ca6 0%, #3a4e1400 82%),\n radial-gradient(60% 45% at 48% 46%, #e8f2c214 0%, #e8f2c200 100%),\n linear-gradient(162deg, #2e3e10 0%, #141a0a 100%)`,\n];\n\nconst VARIANTS = ['a', 'b', 'c', 'd', 'e', 'f'] as const;\n\n/** six frames per bracket, milliseconds apart: same light, a slightly\n * different world */\nconst shots = brackets.flatMap((bracket, row) =>\n VARIANTS.map((variant, i) => ({\n bracket: bracket.no,\n caption: bracket.caption,\n exposure: bracket.exposure,\n id: `n${bracket.no}${variant}`,\n label: String(row * 6 + i + 1).padStart(2, '0'),\n stamp: `07:4${row}:02.${String(114 + i * 83).padStart(3, '0')}`,\n wash: washes[row * 6 + i]!,\n })),\n);\n\n/** a camera has weight: it carries the whole table, so it never snaps —\n but 0.62s with bounce made the dive linger. Shorter, quieter settle. */\nconst carry = spring({ bounce: 0.05, visualDuration: 0.36 });\n\n/** the frames' own boxes, if a pass reflows the sheet */\nconst settle = spring({ bounce: 0.22, visualDuration: 0.42 });\n\n/**\n * The dive's magnification: the photograph's share of the glass on\n * whichever axis fits first. One number for `@fit` — the library derives\n * the zoom and the centring pan from it against the REAL boxes, so a\n * narrow phone and a wide desktop glass both land the frame at this\n * share, never cropped against the edge.\n */\nconst FILL = 0.72;\n\ntype Verdict = 'loved' | 'neutral' | 'passed';\n\nexport class Camera extends Component {\n @tracked focus: string | null = null;\n /** one verdict per shot, from a fixed three: loved it, no call yet\n * (the default), or passed on it — a segmented control, not a toggle */\n @tracked verdicts: Record<string, Verdict> = {};\n\n /** the region's yielded context, held so the HUD outside the glass can\n * read `c.camera` — tracked, updated when a camera step lands (§9) */\n @tracked c: ChoreoContext | null = null;\n\n wire = modifier((_el: Element, [c]: [ChoreoContext]) => {\n this.c = c;\n });\n\n get stageClass() {\n return this.focus ? 'cam-stage is-zoomed' : 'cam-stage';\n }\n\n loupe = (id: string, event: Event) => {\n event.stopPropagation();\n const opening = this.focus !== id;\n if (opening) {\n // `@fit` sizes the dive to fit the GLASS — but the glass itself\n // can be taller than the actual visible viewport (a phone's\n // browser chrome eats real estate the stage's own height doesn't\n // know about), so a corner dive can still land partly below the\n // fold. This is the one place scroll position is a legitimate\n // part of \"does the result fit\" — nudge the glass fully into\n // view along with the dive, not after it. `nearest`, not\n // `center`: a glass already fully on screen must not move — the\n // dive changes the world INSIDE the crop, never the page around\n // it.\n const stage = (event.currentTarget as HTMLElement).closest('.cam-stage');\n if (stage) {\n const box = stage.getBoundingClientRect();\n const view = window.innerHeight;\n // `block: 'nearest'` is a no-op for a glass already fully on screen,\n // which is every glass in a gallery card — so on that page this never\n // scrolled and the rule read as \"a dive never moves the page\". On the\n // demo page the stage is TALLER than the viewport, and `nearest` will\n // happily scroll a box that can never fit, which is the page lurching\n // on a zoom. A stage that cannot fit is one scrolling cannot help.\n const shown =\n Math.max(0, Math.min(box.bottom, view) - Math.max(box.top, 0)) /\n Math.max(1, box.height);\n // Only rescue a glass that is genuinely hidden. `block: 'nearest'`\n // alone moves the page for a stage that is one pixel short of\n // whole, and the stage sizes itself to the viewport — so on the demo\n // page, where a header sits above it, almost every dive nudged the\n // page. In a gallery card the stage is always fully on screen, which\n // is why the rule only ever misbehaved on one of the two pages.\n if (shown < 0.75) {\n stage.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n }\n this.focus = opening ? id : null;\n };\n\n /** a print half past the crop is still a focusable button, and focusing\n * it makes the browser scroll the page chasing a corner the crop has\n * already spoken for — swallow the mousedown's focus, keep the click */\n quellFocus = (event: Event) => {\n event.preventDefault();\n };\n\n clear = () => {\n this.focus = null;\n };\n\n /** the segmented control's own click; it ends the grade, so it never\n * toggles the frame under it too. A second click on the already-active\n * segment is what gets you BACK to neutral — there's no separate\n * \"clear\" control, since neutral is just \"neither one clicked\". */\n setVerdict = (\n shot: (typeof shots)[number],\n verdict: Verdict,\n event: Event,\n ) => {\n event.stopPropagation();\n const next = this.isVerdict(shot, verdict) ? 'neutral' : verdict;\n this.verdicts = { ...this.verdicts, [shot.id]: next };\n this.focus = null;\n // the verdict closes the dock, and the control holding focus goes with\n // it — hand focus back to the print that was graded\n const control = event.currentTarget as HTMLElement;\n if (document.activeElement === control) {\n control\n .closest('.cam')\n ?.querySelector<HTMLElement>(`[data-shot=\"${shot.id}\"]`)\n ?.focus({ preventScroll: true });\n }\n };\n\n isFocus = (id: string) => this.focus === id;\n verdictOf = (shot: (typeof shots)[number]): Verdict =>\n this.verdicts[shot.id] ?? 'neutral';\n isVerdict = (shot: (typeof shots)[number], verdict: Verdict) =>\n this.verdictOf(shot) === verdict;\n\n /** contact-sheet marks only — loved and passed, at rest. Once dived\n * the same facts live in the grade dock, so the print stays clean. */\n showHeart = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'loved') && !this.isFocus(shot.id);\n\n showPass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'passed') && !this.isFocus(shot.id);\n\n heartClass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'loved') ? 'cam-heart is-active' : 'cam-heart';\n\n passClass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'passed') ? 'cam-pass is-active' : 'cam-pass';\n\n get dockLoved() {\n const shot = this.focusedShot;\n return !!shot && this.isVerdict(shot, 'loved');\n }\n\n get dockPassed() {\n const shot = this.focusedShot;\n return !!shot && this.isVerdict(shot, 'passed');\n }\n\n get dockHeartClass() {\n return this.dockLoved\n ? 'cam-heart is-control is-active'\n : 'cam-heart is-control';\n }\n\n get dockPassClass() {\n return this.dockPassed\n ? 'cam-pass is-control is-active'\n : 'cam-pass is-control';\n }\n\n /** the frame's own class: open while dived, dimmed once passed on */\n frameClass = (shot: (typeof shots)[number]) => {\n const classes = ['cam-frame'];\n if (this.isFocus(shot.id)) {\n classes.push('is-open');\n }\n if (this.isVerdict(shot, 'loved')) {\n classes.push('is-loved');\n }\n if (this.isVerdict(shot, 'passed')) {\n classes.push('is-passed');\n }\n return classes.join(' ');\n };\n\n /** where the glass actually stands — the library's own landed camera\n * state, read back instead of a number this component computed. Guarded:\n * a camera measured against a stage that had no box yet (mid-crossing)\n * can read back non-finite, and \"NaN×\" is not a magnification */\n get power() {\n const zoom = this.c?.camera.zoom ?? 1;\n return `${(Number.isFinite(zoom) ? zoom : 1).toFixed(2)}×`;\n }\n\n /** the graded shot, if any — the dock's own content, not a per-frame one */\n get focusedShot() {\n return shots.find((shot) => shot.id === this.focus) ?? null;\n }\n\n <template>\n <div class='ex'>\n <div class='cam'>\n {{! the zoom readout — pinned over the corner, OUTSIDE the region,\n so it never rides the camera; it reads c.camera back instead }}\n <span class='cam-hud-dock'>\n <span class='cam-hud'>{{this.power}}</span>\n </span>\n {{! the cursor is the affordance: zoom-in over a frame, zoom-out on\n the table once you are close — no toolbar, the world explains }}\n <Choreo class={{this.stageClass}} {{on 'click' this.clear}} as |c|>\n {{! the sheet of prints is the demo's SUBSTANCE: the crossing\n matches this box, not the stage frame around it }}\n <div class='cam-sheet' data-choreo-substance {{this.wire c}}>\n {{#each shots as |shot|}}\n <button\n type='button'\n class={{this.frameClass shot}}\n data-shot={{shot.id}}\n {{motion id=shot.id role='frame'}}\n {{on 'click' (fn this.loupe shot.id)}}\n {{on 'mousedown' this.quellFocus}}\n >\n <span class='cam-wash' style={{washOf shot.wash}}></span>\n <span\n class='cam-no'\n {{motion id=(labelId shot) role='no'}}\n >{{shot.label}}</span>\n {{! heart and X on the contact sheet only — when dived they\n live in the grade dock, off the print }}\n {{#unless (this.isFocus shot.id)}}\n <span class='cam-marks'>\n {{#if (this.showHeart shot)}}\n <span\n class={{this.heartClass shot}}\n {{motion id=(heartId shot) role='heart'}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='currentColor'\n d='M12 21s-7.2-4.6-9.6-8.8C.4 8.8 1.5 4.6 5.2 3.4 7.8 2.5 10.2 3.6 12 6.2c1.8-2.6 4.2-3.7 6.8-2.8 3.7 1.2 4.8 5.4 2.8 8.8C19.2 16.4 12 21 12 21z'\n />\n </svg>\n </span>\n {{/if}}\n {{#if (this.showPass shot)}}\n <span\n class={{this.passClass shot}}\n {{motion id=(verdictId shot) role='verdict'}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='none'\n stroke='currentColor'\n stroke-linecap='round'\n stroke-width='2.2'\n d='M7 7l10 10M17 7L7 17'\n />\n </svg>\n </span>\n {{/if}}\n </span>\n {{/unless}}\n </button>\n {{/each}}\n </div>\n\n <c.Parallel>\n {{! ONE camera step, aimed by state. Every pass replays it\n toward wherever the work now stands — an interrupted dive\n simply bends. @fit computes the zoom and the centring pan\n from rest-layout geometry inside the library; null means\n back to the resting sheet. @steady keeps take numbers and\n the other tiles' marks legible while the camera flies. }}\n <c.Camera\n @fit={{if this.focus (c.id this.focus) null}}\n @margin={{FILL}}\n @spring={{tuneSpring 'camera' carry 'carry'}}\n @steady={{array\n (c.role 'no')\n (c.role 'heart')\n (c.role 'verdict')\n }}\n />\n {{! if a verdict reflows the sheet, every frame that moved tweens }}\n <c.Move\n @of={{c.moved 'frame'}}\n @spring={{tuneSpring 'camera' settle 'settle'}}\n />\n </c.Parallel>\n </Choreo>\n\n {{! the grade dock: pinned to the TABLE, not the frame — so the\n dive zooms the photograph and never the reading of it. Heart\n and pass sit HERE, beside the caption, never on the print. }}\n <div\n class={{if\n this.focusedShot\n 'cam-grade-dock is-open'\n 'cam-grade-dock'\n }}\n >\n {{#if this.focusedShot}}\n <div class='cam-grade-copy'>\n <span class='cam-caption'>{{this.focusedShot.caption}}</span>\n <span class='cam-exp'>{{this.focusedShot.exposure}}\n ·\n {{this.focusedShot.stamp}}</span>\n </div>\n <div class='cam-grade-tools'>\n <button\n type='button'\n class={{this.dockHeartClass}}\n aria-label='Love this one'\n aria-pressed={{if this.dockLoved 'true' 'false'}}\n {{on 'click' (fn this.setVerdict this.focusedShot 'loved')}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='currentColor'\n d='M12 21s-7.2-4.6-9.6-8.8C.4 8.8 1.5 4.6 5.2 3.4 7.8 2.5 10.2 3.6 12 6.2c1.8-2.6 4.2-3.7 6.8-2.8 3.7 1.2 4.8 5.4 2.8 8.8C19.2 16.4 12 21 12 21z'\n />\n </svg>\n </button>\n <button\n type='button'\n class={{this.dockPassClass}}\n aria-label='Pass'\n aria-pressed={{if this.dockPassed 'true' 'false'}}\n {{on 'click' (fn this.setVerdict this.focusedShot 'passed')}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='none'\n stroke='currentColor'\n stroke-linecap='round'\n stroke-width='2.2'\n d='M7 7l10 10M17 7L7 17'\n />\n </svg>\n </button>\n </div>\n {{/if}}\n </div>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* The library IS the demo container, edge to edge — no chrome around the\n glass. Both themes get their own canvas via --bg on `.stage-wrap`. */\n .cam {\n position: absolute;\n inset: 0;\n }\n\n /* the photographer's hands: pinned over the table's corner, never riding\n the camera. Same glass-pill language as `.replay`. */\n .cam-hud-dock {\n position: absolute;\n top: 12px;\n right: 14px;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n .cam-hud {\n padding: 5px 10px;\n border-radius: 999px;\n border: 1px solid var(--line-strong);\n background: color-mix(in srgb, var(--bg-elev) 82%, transparent);\n backdrop-filter: blur(8px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n color: var(--ink-dim);\n pointer-events: none;\n font-variant-numeric: tabular-nums;\n }\n\n /* The glass clips: at rest the whole library sits on it.\n The cursor is the affordance: zoom-in over a frame, zoom-out on the\n table once you are close. */\n .cam-stage {\n position: absolute;\n inset: 0;\n /* the zoomed sheet is bigger than the glass BY DESIGN — `clip` crops it\n without making the glass scrollable, so a click on a half-visible print\n can never make the browser \"helpfully\" scroll the crop off its box\n (see .stage-wrap for the full rule) */\n overflow: clip;\n }\n\n .cam-stage.is-zoomed {\n cursor: zoom-out;\n }\n\n /* four columns on a roomy glass — a library, not a contact strip.\n Narrower glass gets fewer, bigger columns, so a dived print is always\n tall enough to sit inside instead of spilling past the edge.\n\n At rest the WHOLE sheet is on the glass, whatever the viewport: the\n tile is bounded by both axes — its share of the glass's width, and the\n width whose 4:3 stack of rows still fits the glass's height. Whichever\n is smaller wins, so nothing ever crops at 1.00×. */\n .cam-sheet {\n --cam-cols: 4;\n --cam-rows: 3;\n --cam-pad: 18px;\n --cam-gap: 10px;\n position: absolute;\n top: 50%;\n left: 0;\n transform: translateY(-50%);\n box-sizing: border-box;\n width: 100cqw;\n padding: var(--cam-pad);\n display: grid;\n grid-template-columns: repeat(\n var(--cam-cols),\n min(\n calc(\n (\n 100cqw - 2 * var(--cam-pad) - (var(--cam-cols) - 1) *\n var(--cam-gap)\n ) /\n var(--cam-cols)\n ),\n calc(\n (\n 100cqh - 2 * var(--cam-pad) - (var(--cam-rows) - 1) *\n var(--cam-gap)\n ) /\n var(--cam-rows) * 4 / 3\n )\n )\n );\n justify-content: center;\n grid-auto-rows: auto;\n gap: var(--cam-gap);\n }\n\n @container (max-width: 640px) {\n .cam-sheet {\n --cam-cols: 3;\n --cam-rows: 4;\n }\n }\n @container (max-width: 420px) {\n .cam-sheet {\n --cam-gap: 8px;\n --cam-pad: 14px;\n }\n }\n @container (max-width: 300px) {\n .cam-sheet {\n --cam-cols: 2;\n --cam-rows: 6;\n --cam-pad: 12px;\n }\n }\n\n /* the print itself: radius and shadow of a cover/shot. No selection ring —\n the cursor and the dive already say which tile is in play. */\n .cam-frame {\n position: relative;\n display: block;\n padding: 0;\n border: 0;\n border-radius: 14px;\n background: transparent;\n appearance: none;\n color: inherit;\n font: inherit;\n cursor: zoom-in;\n overflow: hidden;\n }\n\n .cam-frame:focus {\n outline: none;\n }\n\n .cam-frame.is-open {\n cursor: zoom-out;\n z-index: 1;\n }\n\n /* the take number: a glass chip on the print, Syne like a cover title.\n Dark glass over a photograph in BOTH themes — same reason .lightbox-body\n names its own white instead of inheriting --ink. */\n .cam-no {\n position: absolute;\n bottom: 8px;\n left: 8px;\n z-index: 1;\n padding: 3px 8px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n background: rgba(12, 9, 8, 0.42);\n backdrop-filter: blur(8px);\n font-family: var(--font-display);\n font-size: 11px;\n font-weight: 700;\n letter-spacing: -0.03em;\n color: #fff;\n line-height: 1.2;\n pointer-events: none;\n }\n\n /* the photograph: 4:3, filling the tile — no sprocket gutter around it */\n .cam-wash {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 4 / 3;\n overflow: hidden;\n border-radius: 14px;\n box-shadow:\n 0 10px 22px rgba(var(--shadow-rgb), calc(0.32 * var(--shadow-a))),\n inset 0 0 28px rgba(0, 0, 0, 0.28);\n }\n\n @media (hover: hover) {\n .cam-frame:hover .cam-wash {\n box-shadow:\n 0 14px 30px rgba(var(--shadow-rgb), calc(0.42 * var(--shadow-a))),\n inset 0 0 28px rgba(0, 0, 0, 0.28);\n }\n }\n\n /* the exposure dock: pinned to the table, like `.cam-hud-dock` — never a\n child of the zoomed sheet, so the dive zooms the photograph, not the\n reading of it. Same glass-panel language as `.cam-hud` / `.replay`. */\n .cam-grade-dock {\n position: absolute;\n left: 12px;\n bottom: 12px;\n z-index: 3;\n display: flex;\n align-items: center;\n gap: 14px;\n min-width: 0;\n padding: 10px 12px 11px 14px;\n border-radius: 14px;\n border: 1px solid var(--line-strong);\n background: color-mix(in srgb, var(--bg-elev) 86%, transparent);\n backdrop-filter: blur(12px);\n box-shadow: 0 10px 24px\n rgba(var(--shadow-rgb), calc(0.35 * var(--shadow-a)));\n opacity: 0;\n transform: translateY(6px);\n pointer-events: none;\n transition:\n opacity 180ms var(--ease),\n transform 180ms var(--ease);\n }\n\n .cam-grade-dock.is-open {\n opacity: 1;\n transform: translateY(0);\n pointer-events: auto;\n }\n\n .cam-grade-copy {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .cam-caption {\n font-family: var(--font-display);\n font-size: 13px;\n font-weight: 700;\n letter-spacing: -0.03em;\n line-height: 1.2;\n color: var(--ink);\n }\n\n .cam-exp {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--ink-dim);\n font-variant-numeric: tabular-nums;\n }\n\n .cam-grade-tools {\n display: flex;\n align-items: center;\n gap: 6px;\n flex: none;\n }\n\n /* dock controls sit on the table, not the print — theme tokens, a\n real hit target. Tile marks keep the dark-on-photo chips above. */\n .cam-grade-dock .cam-heart,\n .cam-grade-dock .cam-pass {\n width: 32px;\n height: 32px;\n padding: 0;\n border: 1px solid var(--line-strong);\n background: rgba(var(--surface-tint-rgb), 0.08);\n color: var(--ink-dim);\n box-shadow: none;\n cursor: pointer;\n }\n\n .cam-grade-dock .cam-heart svg,\n .cam-grade-dock .cam-pass svg {\n width: 14px;\n height: 14px;\n }\n\n .cam-grade-dock .cam-heart.is-active {\n background: var(--ember);\n border-color: transparent;\n color: #fff;\n box-shadow: 0 4px 14px rgba(255, 59, 31, 0.45);\n }\n\n /* an X that is ON reads reversed — ink chip, paper glyph — with the same\n conviction as the heart's ember fill; a 12% tint read as \"not pressed\" */\n .cam-grade-dock .cam-pass.is-active {\n background: var(--ink);\n border-color: transparent;\n color: var(--bg);\n }\n\n /* heart and X: the same job as `.cam-no`, the other end of the frame.\n They sit. They do not grow. */\n .cam-marks {\n position: absolute;\n right: 8px;\n bottom: 8px;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 4px;\n pointer-events: none;\n }\n\n .cam-heart,\n .cam-pass {\n display: grid;\n place-items: center;\n width: 18px;\n height: 18px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n background: rgba(12, 9, 8, 0.42);\n backdrop-filter: blur(8px);\n color: rgba(255, 255, 255, 0.7);\n }\n\n .cam-heart svg,\n .cam-pass svg {\n width: 10px;\n height: 10px;\n display: block;\n }\n\n .cam-heart.is-active {\n color: #fff;\n background: var(--ember);\n border-color: transparent;\n box-shadow: 0 4px 14px rgba(255, 59, 31, 0.5);\n }\n\n .cam-pass.is-active {\n color: rgba(255, 255, 255, 0.55);\n background: rgba(12, 9, 8, 0.55);\n }\n\n .cam-heart.is-control,\n .cam-pass.is-control {\n pointer-events: auto;\n cursor: pointer;\n }\n\n /* a passed frame on the contact sheet: the print eases back, that's all.\n The corner X is the mark; a cross through the wash was too much. Once\n dived, full colour so you can still grade. */\n .cam-frame.is-passed:not(.is-open) .cam-wash {\n opacity: 0.38;\n filter: saturate(0.35) brightness(0.78);\n }\n </style>\n </template>\n}\n\nfunction washOf(wash: string) {\n return htmlSafe(`background: ${wash}`);\n}\n\nfunction labelId(shot: { id: string }) {\n return `no-${shot.id}`;\n}\n\nfunction verdictId(shot: { id: string }) {\n return `verdict-${shot.id}`;\n}\n\nfunction heartId(shot: { id: string }) {\n return `heart-${shot.id}`;\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSpring('camera', carry, 'carry');\ntuneSpring('camera', settle, 'settle');\n\nexport class CameraDemo extends GalleryDemo {\n static stage = Camera;\n static notes = CameraNotes;\n}\n";
399
+ var camera_default = "import type { ChoreoContext } from '@cardstack/choreo';\nimport { Choreo } from '@cardstack/choreo';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, spring } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneSpring } from '../lib/tuning';\nimport CameraNotes from '../notes/camera';\n\n/**\n * A photo library. The whole set sits on the glass at once — dive IN onto a\n * shot and the camera info develops: its number first, then, a beat later,\n * the caption, the exposure, and the verdict.\n *\n * The dive is `@fit`: the library computes the zoom AND the pan so the\n * photograph fills a set share of the glass, centred — from rest-layout\n * geometry, so a click that lands mid-flight on a different tile still\n * measures true. The grade panel never has to compete with it for room —\n * it's a callout beside the frame, not a card laid over the photo.\n */\nconst brackets = [\n {\n caption: 'Night shift',\n exposure: 'f/2.8 · 1/60 · ISO 800',\n no: '01',\n },\n {\n caption: 'Foundry glass',\n exposure: 'f/2 · 1/30 · ISO 1600',\n no: '02',\n },\n];\n\n/**\n * Twelve photographs, not twelve cuts of the same amber negative — and\n * not twelve colour swatches either. Each wash is layered like an\n * out-of-focus exposure: a bright bokeh bloom off-centre, a softer\n * counter-glow from another corner, a faint glint, over a dark ground.\n * The framing (where the light sits) varies shot to shot, the way a\n * bracket's frames wander; only the hue family stays per bracket row.\n */\nconst washes = [\n // 01 — night shift: a streetlamp high right, sodium haze low left\n `radial-gradient(150% 120% at 74% 10%, #a9c8f5d9 0%, #8eb8f000 74%),\n radial-gradient(110% 95% at 16% 82%, #3c5c8cb8 0%, #2a4a7800 82%),\n radial-gradient(60% 45% at 54% 44%, #ffffff1f 0%, #ffffff00 100%),\n linear-gradient(158deg, #2c4a74 0%, #101a26 100%)`,\n // 02 — the same street, warmer glass: shopfront glow left of frame\n `radial-gradient(140% 120% at 26% 18%, #ffc07ad4 0%, #ffc07a00 76%),\n radial-gradient(120% 100% at 80% 78%, #d4581caa 0%, #d4581c00 80%),\n radial-gradient(55% 45% at 58% 50%, #ffe6c41c 0%, #ffe6c400 100%),\n linear-gradient(148deg, #a03e14 0%, #301408 100%)`,\n // 03 — a neon sign bleeding through the top of the frame\n `radial-gradient(160% 110% at 60% 4%, #c4a0ffd4 0%, #c4a0ff00 72%),\n radial-gradient(100% 100% at 12% 70%, #6a36b3a8 0%, #5a2a9c00 80%),\n radial-gradient(55% 45% at 78% 54%, #ffffff17 0%, #ffffff00 100%),\n linear-gradient(164deg, #4a2384 0%, #1c1030 100%)`,\n // 04 — window light on water, the bloom sunk low\n `radial-gradient(145% 115% at 68% 86%, #6fd6c4cf 0%, #6fd6c400 76%),\n radial-gradient(110% 90% at 22% 16%, #2a7a78a8 0%, #1a5c5e00 82%),\n radial-gradient(60% 40% at 46% 52%, #c8f2e614 0%, #c8f2e600 100%),\n linear-gradient(152deg, #16494a 0%, #0c1c1e 100%)`,\n // 05 — tail lights, hot centre-right\n `radial-gradient(135% 115% at 66% 32%, #ff8a5ad4 0%, #ff8a5a00 72%),\n radial-gradient(100% 110% at 10% 88%, #c4271299 0%, #c4271200 78%),\n radial-gradient(55% 40% at 32% 22%, #ffd9b829 0%, #ffd9b800 100%),\n linear-gradient(160deg, #8a1e0e 0%, #220c08 100%)`,\n // 06 — overcast daylight, one soft silver source\n `radial-gradient(170% 130% at 38% 6%, #d0d6dacf 0%, #d0d6da00 78%),\n radial-gradient(110% 90% at 84% 80%, #6a737694 0%, #5c656800 82%),\n radial-gradient(65% 50% at 60% 46%, #f2f5f614 0%, #f2f5f600 100%),\n linear-gradient(170deg, #4c5457 0%, #1a1815 100%)`,\n // 07 — the foundry door cracked open, low left\n `radial-gradient(140% 120% at 20% 84%, #ff7a45d1 0%, #ff7a4500 74%),\n radial-gradient(110% 95% at 78% 20%, #c42712a3 0%, #c4271200 80%),\n radial-gradient(55% 45% at 48% 54%, #ffc79e1a 0%, #ffc79e00 100%),\n linear-gradient(160deg, #7e1c0c 0%, #2e100a 100%)`,\n // 08 — pour in frame: the melt blown out top left, sparks right\n `radial-gradient(145% 115% at 24% 12%, #ffb36ad6 0%, #ffb36a00 74%),\n radial-gradient(105% 110% at 84% 64%, #ff3b1f9e 0%, #ff3b1f00 76%),\n radial-gradient(50% 40% at 60% 32%, #fff1d633 0%, #fff1d600 100%),\n linear-gradient(145deg, #b2320f 0%, #4a1610 100%)`,\n // 09 — smoke and skylight, silver from above\n `radial-gradient(165% 120% at 56% 2%, #c5cdd0cf 0%, #c5cdd000 76%),\n radial-gradient(100% 100% at 14% 78%, #6a737691 0%, #5c656800 82%),\n radial-gradient(60% 45% at 70% 50%, #eef2f414 0%, #eef2f400 100%),\n linear-gradient(165deg, #474f52 0%, #1c1a16 100%)`,\n // 10 — blown-out furnace glass, nearly the whole frame\n `radial-gradient(155% 125% at 62% 28%, #fff4e8db 0%, #fff4e800 78%),\n radial-gradient(120% 100% at 14% 84%, #e4a35ab3 0%, #e4a35a00 80%),\n linear-gradient(150deg, #b57838 0%, #5c3a1c 100%)`,\n // 11 — arc light through violet gel, high left\n `radial-gradient(140% 115% at 22% 8%, #b08cffd4 0%, #b08cff00 74%),\n radial-gradient(110% 95% at 80% 74%, #5c2494ad 0%, #4a187800 80%),\n radial-gradient(55% 45% at 50% 48%, #ffffff1a 0%, #ffffff00 100%),\n linear-gradient(156deg, #3c1464 0%, #180c26 100%)`,\n // 12 — sodium on brass, acid bloom low right\n `radial-gradient(145% 115% at 78% 76%, #b6d15acf 0%, #b6d15a00 76%),\n radial-gradient(105% 90% at 18% 18%, #5a742ca6 0%, #3a4e1400 82%),\n radial-gradient(60% 45% at 48% 46%, #e8f2c214 0%, #e8f2c200 100%),\n linear-gradient(162deg, #2e3e10 0%, #141a0a 100%)`,\n];\n\nconst VARIANTS = ['a', 'b', 'c', 'd', 'e', 'f'] as const;\n\n/** six frames per bracket, milliseconds apart: same light, a slightly\n * different world */\nconst shots = brackets.flatMap((bracket, row) =>\n VARIANTS.map((variant, i) => ({\n bracket: bracket.no,\n caption: bracket.caption,\n exposure: bracket.exposure,\n id: `n${bracket.no}${variant}`,\n label: String(row * 6 + i + 1).padStart(2, '0'),\n stamp: `07:4${row}:02.${String(114 + i * 83).padStart(3, '0')}`,\n wash: washes[row * 6 + i]!,\n })),\n);\n\n/** a camera has weight: it carries the whole table, so it never snaps —\n but 0.62s with bounce made the dive linger. Shorter, quieter settle. */\nconst carry = spring({ bounce: 0.05, visualDuration: 0.36 });\n\n/** the frames' own boxes, if a pass reflows the sheet */\nconst settle = spring({ bounce: 0.22, visualDuration: 0.42 });\n\n/**\n * The dive's magnification: the photograph's share of the glass on\n * whichever axis fits first. One number for `@fit` — the library derives\n * the zoom and the centring pan from it against the REAL boxes, so a\n * narrow phone and a wide desktop glass both land the frame at this\n * share, never cropped against the edge.\n */\nconst FILL = 0.72;\n\ntype Verdict = 'loved' | 'neutral' | 'passed';\n\nexport class Camera extends Component {\n @tracked focus: string | null = null;\n /** one verdict per shot, from a fixed three: loved it, no call yet\n * (the default), or passed on it — a segmented control, not a toggle */\n @tracked verdicts: Record<string, Verdict> = {};\n\n /** the region's yielded context, held so the HUD outside the glass can\n * read `c.camera` — tracked, updated when a camera step lands (§9) */\n @tracked c: ChoreoContext | null = null;\n\n wire = modifier((_el: Element, [c]: [ChoreoContext]) => {\n this.c = c;\n });\n\n get stageClass() {\n return this.focus ? 'cam-stage is-zoomed' : 'cam-stage';\n }\n\n loupe = (id: string, event: Event) => {\n event.stopPropagation();\n const opening = this.focus !== id;\n if (opening) {\n // `@fit` sizes the dive to fit the GLASS — but the glass itself\n // can be taller than the actual visible viewport (a phone's\n // browser chrome eats real estate the stage's own height doesn't\n // know about), so a corner dive can still land partly below the\n // fold. This is the one place scroll position is a legitimate\n // part of \"does the result fit\" — nudge the glass fully into\n // view along with the dive, not after it. `nearest`, not\n // `center`: a glass already fully on screen must not move — the\n // dive changes the world INSIDE the crop, never the page around\n // it.\n const stage = (event.currentTarget as HTMLElement).closest('.cam-stage');\n if (stage) {\n const box = stage.getBoundingClientRect();\n const view = window.innerHeight;\n // `block: 'nearest'` is a no-op for a glass already fully on screen,\n // which is every glass in a gallery card — so on that page this never\n // scrolled and the rule read as \"a dive never moves the page\". On the\n // demo page the stage is TALLER than the viewport, and `nearest` will\n // happily scroll a box that can never fit, which is the page lurching\n // on a zoom. A stage that cannot fit is one scrolling cannot help.\n const shown =\n Math.max(0, Math.min(box.bottom, view) - Math.max(box.top, 0)) /\n Math.max(1, box.height);\n // Only rescue a glass that is genuinely hidden. `block: 'nearest'`\n // alone moves the page for a stage that is one pixel short of\n // whole, and the stage sizes itself to the viewport — so on the demo\n // page, where a header sits above it, almost every dive nudged the\n // page. In a gallery card the stage is always fully on screen, which\n // is why the rule only ever misbehaved on one of the two pages.\n if (shown < 0.75) {\n stage.scrollIntoView({ behavior: 'smooth', block: 'nearest' });\n }\n }\n }\n this.focus = opening ? id : null;\n // The open print holds focus, so Escape reaches this stage's handler\n // after a pointer dive as well as a keyboard one. preventScroll leaves\n // the page where it is, which is what quellFocus keeps the mousedown's\n // own focus from doing, and focusVisible: false keeps a pointer dive\n // ringless. A print already focused from the keyboard keeps its ring.\n if (opening) {\n // `focusVisible` is a standard FocusOptions member the DOM lib\n // doesn't type yet\n const options: FocusOptions & { focusVisible?: boolean } = {\n preventScroll: true,\n focusVisible: false,\n };\n (event.currentTarget as HTMLElement).focus(options);\n }\n };\n\n /** a print half past the crop is still a focusable button, and focusing\n * it makes the browser scroll the page chasing a corner the crop has\n * already spoken for — swallow the mousedown's focus, keep the click */\n quellFocus = (event: Event) => {\n event.preventDefault();\n };\n\n clear = () => {\n this.focus = null;\n };\n\n /** Escape backs out of a dive, as a click on the table does. Focus goes to\n * the print that was open, since the dock that may have held it closes.\n * With nothing open, Escape is the host's. */\n escape = modifier((el: HTMLElement) => {\n const key = (event: KeyboardEvent) => {\n const open = this.focus;\n if (event.key !== 'Escape' || event.defaultPrevented || !open) {\n return;\n }\n event.preventDefault();\n event.stopPropagation();\n this.focus = null;\n el.querySelector<HTMLElement>(`[data-shot=\"${open}\"]`)?.focus({\n preventScroll: true,\n });\n };\n el.addEventListener('keydown', key);\n return () => el.removeEventListener('keydown', key);\n });\n\n /** the segmented control's own click; it ends the grade, so it never\n * toggles the frame under it too. A second click on the already-active\n * segment is what gets you BACK to neutral — there's no separate\n * \"clear\" control, since neutral is just \"neither one clicked\". */\n setVerdict = (\n shot: (typeof shots)[number],\n verdict: Verdict,\n event: Event,\n ) => {\n event.stopPropagation();\n const next = this.isVerdict(shot, verdict) ? 'neutral' : verdict;\n this.verdicts = { ...this.verdicts, [shot.id]: next };\n this.focus = null;\n // the verdict closes the dock, and the control holding focus goes with\n // it — hand focus back to the print that was graded\n const control = event.currentTarget as HTMLElement;\n if (document.activeElement === control) {\n control\n .closest('.cam')\n ?.querySelector<HTMLElement>(`[data-shot=\"${shot.id}\"]`)\n ?.focus({ preventScroll: true });\n }\n };\n\n isFocus = (id: string) => this.focus === id;\n verdictOf = (shot: (typeof shots)[number]): Verdict =>\n this.verdicts[shot.id] ?? 'neutral';\n isVerdict = (shot: (typeof shots)[number], verdict: Verdict) =>\n this.verdictOf(shot) === verdict;\n\n /** contact-sheet marks only — loved and passed, at rest. Once dived\n * the same facts live in the grade dock, so the print stays clean. */\n showHeart = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'loved') && !this.isFocus(shot.id);\n\n showPass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'passed') && !this.isFocus(shot.id);\n\n heartClass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'loved') ? 'cam-heart is-active' : 'cam-heart';\n\n passClass = (shot: (typeof shots)[number]) =>\n this.isVerdict(shot, 'passed') ? 'cam-pass is-active' : 'cam-pass';\n\n get dockLoved() {\n const shot = this.focusedShot;\n return !!shot && this.isVerdict(shot, 'loved');\n }\n\n get dockPassed() {\n const shot = this.focusedShot;\n return !!shot && this.isVerdict(shot, 'passed');\n }\n\n get dockHeartClass() {\n return this.dockLoved\n ? 'cam-heart is-control is-active'\n : 'cam-heart is-control';\n }\n\n get dockPassClass() {\n return this.dockPassed\n ? 'cam-pass is-control is-active'\n : 'cam-pass is-control';\n }\n\n /** the frame's own class: open while dived, dimmed once passed on */\n frameClass = (shot: (typeof shots)[number]) => {\n const classes = ['cam-frame'];\n if (this.isFocus(shot.id)) {\n classes.push('is-open');\n }\n if (this.isVerdict(shot, 'loved')) {\n classes.push('is-loved');\n }\n if (this.isVerdict(shot, 'passed')) {\n classes.push('is-passed');\n }\n return classes.join(' ');\n };\n\n /** where the glass actually stands — the library's own landed camera\n * state, read back instead of a number this component computed. Guarded:\n * a camera measured against a stage that had no box yet (mid-crossing)\n * can read back non-finite, and \"NaN×\" is not a magnification */\n get power() {\n const zoom = this.c?.camera.zoom ?? 1;\n return `${(Number.isFinite(zoom) ? zoom : 1).toFixed(2)}×`;\n }\n\n /** the graded shot, if any — the dock's own content, not a per-frame one */\n get focusedShot() {\n return shots.find((shot) => shot.id === this.focus) ?? null;\n }\n\n <template>\n <div class='ex'>\n <div class='cam' {{this.escape}}>\n {{! the zoom readout — pinned over the corner, OUTSIDE the region,\n so it never rides the camera; it reads c.camera back instead }}\n <span class='cam-hud-dock'>\n <span class='cam-hud'>{{this.power}}</span>\n </span>\n {{! the cursor is the affordance: zoom-in over a frame, zoom-out on\n the table once you are close — no toolbar, the world explains }}\n <Choreo class={{this.stageClass}} {{on 'click' this.clear}} as |c|>\n {{! the sheet of prints is the demo's SUBSTANCE: the crossing\n matches this box, not the stage frame around it }}\n <div class='cam-sheet' data-choreo-substance {{this.wire c}}>\n {{#each shots as |shot|}}\n <button\n type='button'\n class={{this.frameClass shot}}\n data-shot={{shot.id}}\n aria-pressed={{if (this.isFocus shot.id) 'true' 'false'}}\n {{motion id=shot.id role='frame'}}\n {{on 'click' (fn this.loupe shot.id)}}\n {{on 'mousedown' this.quellFocus}}\n data-test-cam-print={{shot.id}}\n >\n <span class='cam-wash' style={{washOf shot.wash}}></span>\n <span\n class='cam-no'\n {{motion id=(labelId shot) role='no'}}\n >{{shot.label}}</span>\n {{! heart and X on the contact sheet only — when dived they\n live in the grade dock, off the print }}\n {{#unless (this.isFocus shot.id)}}\n <span class='cam-marks'>\n {{#if (this.showHeart shot)}}\n <span\n class={{this.heartClass shot}}\n {{motion id=(heartId shot) role='heart'}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='currentColor'\n d='M12 21s-7.2-4.6-9.6-8.8C.4 8.8 1.5 4.6 5.2 3.4 7.8 2.5 10.2 3.6 12 6.2c1.8-2.6 4.2-3.7 6.8-2.8 3.7 1.2 4.8 5.4 2.8 8.8C19.2 16.4 12 21 12 21z'\n />\n </svg>\n </span>\n {{/if}}\n {{#if (this.showPass shot)}}\n <span\n class={{this.passClass shot}}\n {{motion id=(verdictId shot) role='verdict'}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='none'\n stroke='currentColor'\n stroke-linecap='round'\n stroke-width='2.2'\n d='M7 7l10 10M17 7L7 17'\n />\n </svg>\n </span>\n {{/if}}\n </span>\n {{/unless}}\n </button>\n {{/each}}\n </div>\n\n <c.Parallel>\n {{! ONE camera step, aimed by state. Every pass replays it\n toward wherever the work now stands — an interrupted dive\n simply bends. @fit computes the zoom and the centring pan\n from rest-layout geometry inside the library; null means\n back to the resting sheet. @steady keeps take numbers and\n the other tiles' marks legible while the camera flies. }}\n <c.Camera\n @fit={{if this.focus (c.id this.focus) null}}\n @margin={{FILL}}\n @spring={{tuneSpring 'camera' carry 'carry'}}\n @steady={{array\n (c.role 'no')\n (c.role 'heart')\n (c.role 'verdict')\n }}\n />\n {{! if a verdict reflows the sheet, every frame that moved tweens }}\n <c.Move\n @of={{c.moved 'frame'}}\n @spring={{tuneSpring 'camera' settle 'settle'}}\n />\n </c.Parallel>\n </Choreo>\n\n {{! the grade dock: pinned to the TABLE, not the frame — so the\n dive zooms the photograph and never the reading of it. Heart\n and pass sit HERE, beside the caption, never on the print. }}\n <div\n class={{if\n this.focusedShot\n 'cam-grade-dock is-open'\n 'cam-grade-dock'\n }}\n >\n {{#if this.focusedShot}}\n <div class='cam-grade-copy'>\n <span class='cam-caption'>{{this.focusedShot.caption}}</span>\n <span class='cam-exp'>{{this.focusedShot.exposure}}\n ·\n {{this.focusedShot.stamp}}</span>\n </div>\n <div class='cam-grade-tools'>\n <button\n type='button'\n class={{this.dockHeartClass}}\n aria-label='Love this one'\n aria-pressed={{if this.dockLoved 'true' 'false'}}\n {{on 'click' (fn this.setVerdict this.focusedShot 'loved')}}\n data-test-cam-love\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='currentColor'\n d='M12 21s-7.2-4.6-9.6-8.8C.4 8.8 1.5 4.6 5.2 3.4 7.8 2.5 10.2 3.6 12 6.2c1.8-2.6 4.2-3.7 6.8-2.8 3.7 1.2 4.8 5.4 2.8 8.8C19.2 16.4 12 21 12 21z'\n />\n </svg>\n </button>\n <button\n type='button'\n class={{this.dockPassClass}}\n aria-label='Pass'\n aria-pressed={{if this.dockPassed 'true' 'false'}}\n {{on 'click' (fn this.setVerdict this.focusedShot 'passed')}}\n >\n <svg viewBox='0 0 24 24' aria-hidden='true'>\n <path\n fill='none'\n stroke='currentColor'\n stroke-linecap='round'\n stroke-width='2.2'\n d='M7 7l10 10M17 7L7 17'\n />\n </svg>\n </button>\n </div>\n {{/if}}\n </div>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* The library IS the demo container, edge to edge — no chrome around the\n glass. Both themes get their own canvas via --bg on `.stage-wrap`. */\n .cam {\n position: absolute;\n inset: 0;\n }\n\n /* the photographer's hands: pinned over the table's corner, never riding\n the camera. Same glass-pill language as `.replay`. */\n .cam-hud-dock {\n position: absolute;\n top: 12px;\n right: 14px;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 6px;\n }\n\n .cam-hud {\n padding: 5px 10px;\n border-radius: 999px;\n border: 1px solid var(--line-strong);\n background: color-mix(in srgb, var(--bg-elev) 82%, transparent);\n backdrop-filter: blur(8px);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n color: var(--ink-dim);\n pointer-events: none;\n font-variant-numeric: tabular-nums;\n }\n\n /* The glass clips: at rest the whole library sits on it.\n The cursor is the affordance: zoom-in over a frame, zoom-out on the\n table once you are close. */\n .cam-stage {\n position: absolute;\n inset: 0;\n /* the zoomed sheet is bigger than the glass BY DESIGN — `clip` crops it\n without making the glass scrollable, so a click on a half-visible print\n can never make the browser \"helpfully\" scroll the crop off its box\n (see .stage-wrap for the full rule) */\n overflow: clip;\n }\n\n .cam-stage.is-zoomed {\n cursor: zoom-out;\n }\n\n /* four columns on a roomy glass — a library, not a contact strip.\n Narrower glass gets fewer, bigger columns, so a dived print is always\n tall enough to sit inside instead of spilling past the edge.\n\n At rest the WHOLE sheet is on the glass, whatever the viewport: the\n tile is bounded by both axes — its share of the glass's width, and the\n width whose 4:3 stack of rows still fits the glass's height. Whichever\n is smaller wins, so nothing ever crops at 1.00×. */\n .cam-sheet {\n --cam-cols: 4;\n --cam-rows: 3;\n --cam-pad: 18px;\n --cam-gap: 10px;\n position: absolute;\n top: 50%;\n left: 0;\n transform: translateY(-50%);\n box-sizing: border-box;\n width: 100cqw;\n padding: var(--cam-pad);\n display: grid;\n grid-template-columns: repeat(\n var(--cam-cols),\n min(\n calc(\n (\n 100cqw - 2 * var(--cam-pad) - (var(--cam-cols) - 1) *\n var(--cam-gap)\n ) /\n var(--cam-cols)\n ),\n calc(\n (\n 100cqh - 2 * var(--cam-pad) - (var(--cam-rows) - 1) *\n var(--cam-gap)\n ) /\n var(--cam-rows) * 4 / 3\n )\n )\n );\n justify-content: center;\n grid-auto-rows: auto;\n gap: var(--cam-gap);\n }\n\n @container (max-width: 640px) {\n .cam-sheet {\n --cam-cols: 3;\n --cam-rows: 4;\n }\n }\n @container (max-width: 420px) {\n .cam-sheet {\n --cam-gap: 8px;\n --cam-pad: 14px;\n }\n }\n @container (max-width: 300px) {\n .cam-sheet {\n --cam-cols: 2;\n --cam-rows: 6;\n --cam-pad: 12px;\n }\n }\n\n /* the print itself: radius and shadow of a cover/shot. No selection ring —\n the cursor and the dive already say which tile is in play. A keyboard\n still gets the site's focus ring; a pointer dive focuses its print\n without one (see loupe). */\n .cam-frame {\n position: relative;\n display: block;\n padding: 0;\n border: 0;\n border-radius: 14px;\n background: transparent;\n appearance: none;\n color: inherit;\n font: inherit;\n cursor: zoom-in;\n overflow: hidden;\n }\n\n .cam-frame.is-open {\n cursor: zoom-out;\n z-index: 1;\n }\n\n /* the take number: a glass chip on the print, Syne like a cover title.\n Dark glass over a photograph in BOTH themes — same reason .lightbox-body\n names its own white instead of inheriting --ink. */\n .cam-no {\n position: absolute;\n bottom: 8px;\n left: 8px;\n z-index: 1;\n padding: 3px 8px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n background: rgba(12, 9, 8, 0.42);\n backdrop-filter: blur(8px);\n font-family: var(--font-display);\n font-size: 11px;\n font-weight: 700;\n letter-spacing: -0.03em;\n color: #fff;\n line-height: 1.2;\n pointer-events: none;\n }\n\n /* the photograph: 4:3, filling the tile — no sprocket gutter around it */\n .cam-wash {\n position: relative;\n display: block;\n width: 100%;\n aspect-ratio: 4 / 3;\n overflow: hidden;\n border-radius: 14px;\n box-shadow:\n 0 10px 22px rgba(var(--shadow-rgb), calc(0.32 * var(--shadow-a))),\n inset 0 0 28px rgba(0, 0, 0, 0.28);\n }\n\n @media (hover: hover) {\n .cam-frame:hover .cam-wash {\n box-shadow:\n 0 14px 30px rgba(var(--shadow-rgb), calc(0.42 * var(--shadow-a))),\n inset 0 0 28px rgba(0, 0, 0, 0.28);\n }\n }\n\n /* the exposure dock: pinned to the table, like `.cam-hud-dock` — never a\n child of the zoomed sheet, so the dive zooms the photograph, not the\n reading of it. Same glass-panel language as `.cam-hud` / `.replay`. */\n .cam-grade-dock {\n position: absolute;\n left: 12px;\n bottom: 12px;\n z-index: 3;\n display: flex;\n align-items: center;\n gap: 14px;\n min-width: 0;\n padding: 10px 12px 11px 14px;\n border-radius: 14px;\n border: 1px solid var(--line-strong);\n background: color-mix(in srgb, var(--bg-elev) 86%, transparent);\n backdrop-filter: blur(12px);\n box-shadow: 0 10px 24px\n rgba(var(--shadow-rgb), calc(0.35 * var(--shadow-a)));\n opacity: 0;\n transform: translateY(6px);\n pointer-events: none;\n transition:\n opacity 180ms var(--ease),\n transform 180ms var(--ease);\n }\n\n .cam-grade-dock.is-open {\n opacity: 1;\n transform: translateY(0);\n pointer-events: auto;\n }\n\n .cam-grade-copy {\n display: flex;\n flex-direction: column;\n gap: 2px;\n min-width: 0;\n }\n\n .cam-caption {\n font-family: var(--font-display);\n font-size: 13px;\n font-weight: 700;\n letter-spacing: -0.03em;\n line-height: 1.2;\n color: var(--ink);\n }\n\n .cam-exp {\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--ink-dim);\n font-variant-numeric: tabular-nums;\n }\n\n .cam-grade-tools {\n display: flex;\n align-items: center;\n gap: 6px;\n flex: none;\n }\n\n /* dock controls sit on the table, not the print — theme tokens, a\n real hit target. Tile marks keep the dark-on-photo chips above. */\n .cam-grade-dock .cam-heart,\n .cam-grade-dock .cam-pass {\n width: 32px;\n height: 32px;\n padding: 0;\n border: 1px solid var(--line-strong);\n background: rgba(var(--surface-tint-rgb), 0.08);\n color: var(--ink-dim);\n box-shadow: none;\n cursor: pointer;\n }\n\n .cam-grade-dock .cam-heart svg,\n .cam-grade-dock .cam-pass svg {\n width: 14px;\n height: 14px;\n }\n\n .cam-grade-dock .cam-heart.is-active {\n background: var(--ember);\n border-color: transparent;\n color: #fff;\n box-shadow: 0 4px 14px rgba(255, 59, 31, 0.45);\n }\n\n /* an X that is ON reads reversed — ink chip, paper glyph — with the same\n conviction as the heart's ember fill; a 12% tint read as \"not pressed\" */\n .cam-grade-dock .cam-pass.is-active {\n background: var(--ink);\n border-color: transparent;\n color: var(--bg);\n }\n\n /* heart and X: the same job as `.cam-no`, the other end of the frame.\n They sit. They do not grow. */\n .cam-marks {\n position: absolute;\n right: 8px;\n bottom: 8px;\n z-index: 2;\n display: flex;\n align-items: center;\n gap: 4px;\n pointer-events: none;\n }\n\n .cam-heart,\n .cam-pass {\n display: grid;\n place-items: center;\n width: 18px;\n height: 18px;\n border-radius: 999px;\n border: 1px solid rgba(255, 255, 255, 0.18);\n background: rgba(12, 9, 8, 0.42);\n backdrop-filter: blur(8px);\n color: rgba(255, 255, 255, 0.7);\n }\n\n .cam-heart svg,\n .cam-pass svg {\n width: 10px;\n height: 10px;\n display: block;\n }\n\n .cam-heart.is-active {\n color: #fff;\n background: var(--ember);\n border-color: transparent;\n box-shadow: 0 4px 14px rgba(255, 59, 31, 0.5);\n }\n\n .cam-pass.is-active {\n color: rgba(255, 255, 255, 0.55);\n background: rgba(12, 9, 8, 0.55);\n }\n\n .cam-heart.is-control,\n .cam-pass.is-control {\n pointer-events: auto;\n cursor: pointer;\n }\n\n /* a passed frame on the contact sheet: the print eases back, that's all.\n The corner X is the mark; a cross through the wash was too much. Once\n dived, full colour so you can still grade. */\n .cam-frame.is-passed:not(.is-open) .cam-wash {\n opacity: 0.38;\n filter: saturate(0.35) brightness(0.78);\n }\n </style>\n </template>\n}\n\nfunction washOf(wash: string) {\n return htmlSafe(`background: ${wash}`);\n}\n\nfunction labelId(shot: { id: string }) {\n return `no-${shot.id}`;\n}\n\nfunction verdictId(shot: { id: string }) {\n return `verdict-${shot.id}`;\n}\n\nfunction heartId(shot: { id: string }) {\n return `heart-${shot.id}`;\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSpring('camera', carry, 'carry');\ntuneSpring('camera', settle, 'settle');\n\nexport class CameraDemo extends GalleryDemo {\n static stage = Camera;\n static notes = CameraNotes;\n}\n";
400
400
  //#endregion
401
401
  //#region ../choreo-gallery/realm/stages/circle-loop.gts?raw
402
402
  var circle_loop_default = "import { Choreo } from '@cardstack/choreo';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, styles } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneNumber } from '../lib/tuning';\n\nconst ID = 'circle-loop';\nconst times = [0, 0.12, 0.28, 0.39, 0.5, 0.63, 0.78, 0.91, 1];\nconst palette = [\n '#8a81ce',\n '#8bb6ce',\n '#d3af7b',\n '#c778c9',\n '#89cfc8',\n '#8fcea1',\n '#a2b8db',\n '#d4df87',\n '#cf9389',\n];\nconst cluster = [\n [-38, -35, 1.05],\n [-36, 36, 1.3],\n [29, -28, 1.15],\n [15, 65, 0.85],\n [42, 24, 1],\n [3, -70, 0.5],\n [67, -1, 0.62],\n [-68, 0, 0.64],\n [-2, 0, 0.54],\n];\nfunction settings() {\n return {\n duration: tuneNumber(ID, 8, 'Cycle duration', 4, 16, 0.1),\n zoom: tuneNumber(ID, 2.8, 'Zoom (×)', 1, 4, 0.1),\n spacing: tuneNumber(ID, 95, 'Grid spacing (px)', 60, 120, 1),\n scatter: tuneNumber(ID, 160, 'Scatter radius (px)', 80, 230, 1),\n haze: tuneNumber(ID, 0.42, 'Field opacity', 0.1, 0.7, 0.01),\n };\n}\nsettings();\nexport class CircleLoop extends Component {\n @tracked ready = false;\n @tracked reduced = true;\n mount = modifier(() => {\n const preference = matchMedia('(prefers-reduced-motion: reduce)');\n const update = () => {\n this.reduced = preference.matches;\n };\n const frame = requestAnimationFrame(() => {\n update();\n this.ready = true;\n });\n preference.addEventListener('change', update);\n return () => {\n cancelAnimationFrame(frame);\n preference.removeEventListener('change', update);\n };\n });\n times = times;\n get score() {\n const { duration, zoom, spacing, scatter, haze } = settings();\n const circles = Array.from({ length: 45 }, (_, i) => {\n const hero = i < 9;\n const [cx, cy, size] = cluster[i % 9]! as [number, number, number];\n const gx = ((i % 3) - 1) * spacing;\n const gy = (Math.floor((i % 9) / 3) - 1) * spacing;\n const angle = i * 2.399963;\n const radius = scatter * Math.sqrt((i + 1) / 45);\n const sx = Math.cos(angle) * radius;\n const sy = Math.sin(angle) * radius;\n const still = this.reduced;\n return {\n id: `circle-${i}`,\n style: styles({\n backgroundColor: palette[i % 9]!,\n }),\n x: still ? times.map(() => cx) : [cx, cx, sx, gx, gx, gx, sx, cx, cx],\n y: still ? times.map(() => cy) : [cy, cy, sy, gy, gy, gy, sy, cy, cy],\n scale: still\n ? times.map(() => (hero ? size : 0.12))\n : hero\n ? [size, size, 0.65, 1.65, 1.65, 1.65, 0.65, size, size]\n : [0.12, 0.12, 0.45, 0.45, 0.45, 0.45, 0.45, 0.12, 0.12],\n opacity: still\n ? times.map(() => (hero ? 1 : 0))\n : hero\n ? [1, 1, 0.7, 1, 1, 1, 0.7, 1, 1]\n : [0, 0, haze, 0, 0, 0, haze, 0, 0],\n };\n });\n return {\n duration: this.reduced ? 0.001 : duration,\n repeat: this.reduced ? 0 : Infinity,\n disc: this.reduced ? times.map(() => 1) : [1, 1, 0, 0, 0, 0, 0, 1, 1],\n circles,\n zoom: this.reduced\n ? times.map(() => 1)\n : [1, 1, zoom, zoom, 1, 1, zoom, 1, 1],\n };\n }\n <template>\n <div\n class='circle-loop-stage'\n data-test-circle-loop\n {{this.mount}}\n aria-label='Circle study: cluster, field, grid, and return'\n >\n {{#let this.score as |score|}}\n <Choreo class='circle-loop-region' as |c|>\n <div class='circle-loop-plane' {{motion id='circle-plane'}}>\n <div class='circle-loop-disc' {{motion id='circle-disc'}}></div>\n {{#each score.circles key='id' as |circle|}}\n <div\n class='circle-loop-dot'\n data-test-circle={{circle.id}}\n {{motion id=circle.id style=circle.style}}\n ></div>\n {{/each}}\n </div>\n {{#if this.ready}}<c.Parallel>\n <c.Tween\n @of={{c.id 'circle-disc'}}\n @opacity={{score.disc}}\n @times={{this.times}}\n @duration={{score.duration}}\n @repeat={{score.repeat}}\n @ease='easeInOut'\n />\n <c.Tween\n @of={{c.id 'circle-plane'}}\n @scale={{score.zoom}}\n @times={{this.times}}\n @duration={{score.duration}}\n @repeat={{score.repeat}}\n @ease='easeInOut'\n />\n {{#each score.circles key='id' as |circle|}}\n <c.Tween\n @of={{c.id circle.id}}\n @x={{circle.x}}\n @y={{circle.y}}\n @scale={{circle.scale}}\n @opacity={{circle.opacity}}\n @times={{this.times}}\n @duration={{score.duration}}\n @repeat={{score.repeat}}\n @ease='easeInOut'\n />\n {{/each}}\n </c.Parallel>{{/if}}\n </Choreo>\n {{/let}}\n <span class='circle-loop-caption'>CLUSTER / FIELD / GRID</span>\n </div>\n <style scoped>\n .circle-loop-stage {\n position: relative;\n width: 100%;\n height: 380px;\n overflow: hidden;\n background: #fafaf8;\n border-radius: 16px;\n }\n .circle-loop-plane {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 0;\n height: 0;\n }\n .circle-loop-dot {\n position: absolute;\n left: -23px;\n top: -23px;\n width: 46px;\n height: 46px;\n border-radius: 50%;\n }\n .circle-loop-disc {\n position: absolute;\n left: -95px;\n top: -95px;\n width: 190px;\n height: 190px;\n border-radius: 50%;\n background: #eaeae7;\n }\n .circle-loop-caption {\n position: absolute;\n bottom: 16px;\n left: 0;\n right: 0;\n text-align: center;\n font: 10px monospace;\n letter-spacing: 0.2em;\n color: #777;\n }\n\n .circle-loop-region {\n position: relative;\n width: 100%;\n height: 100%;\n }\n\n /* The room forces dark mode; the guide still follows the visitor's theme. */\n .choreo-site:not([data-theme='light']) .circle-loop-stage,\n [data-theme='dark'] .circle-loop-stage {\n background: #1d1917;\n }\n .choreo-site:not([data-theme='light']) .circle-loop-disc,\n [data-theme='dark'] .circle-loop-disc {\n background: #38312c;\n }\n .choreo-site:not([data-theme='light']) .circle-loop-caption,\n [data-theme='dark'] .circle-loop-caption {\n color: #c4b4a5;\n }\n </style>\n </template>\n}\n\nexport class CircleLoopDemo extends GalleryDemo {\n static stage = CircleLoop;\n}\n";
@@ -429,7 +429,7 @@ var gestures_default = "import { motion } from 'glimmer-motion';\n\nimport { Gal
429
429
  var grid_default = "import { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { ReorderGroup, ReorderItem } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneObject } from '../lib/tuning';\n\n/**\n * Six covers, six hues. The rest of the gallery is one ember palette on black,\n * which is fine when a stage has two or three things in it and useless here:\n * reordering is only legible if you can tell at a glance WHICH tile moved, and\n * six shades of the same orange cannot do that.\n */\nconst albums = [\n {\n id: 'atlas',\n label: 'Atlas',\n year: '24',\n wash: 'linear-gradient(160deg, #ff7a45, #7a1408)',\n },\n {\n id: 'ember',\n label: 'Ember',\n year: '24',\n wash: 'linear-gradient(160deg, #6fd6c4, #0d3f42)',\n },\n {\n id: 'flux',\n label: 'Flux',\n year: '23',\n wash: 'linear-gradient(160deg, #8ba6ff, #1b1f52)',\n },\n {\n id: 'halo',\n label: 'Halo',\n year: '23',\n wash: 'linear-gradient(160deg, #f3ece3, #9c6b3c)',\n },\n {\n id: 'ion',\n label: 'Ion',\n year: '22',\n wash: 'linear-gradient(160deg, #c78bff, #341250)',\n },\n {\n id: 'ore',\n label: 'Ore',\n year: '22',\n wash: 'linear-gradient(160deg, #b6d15a, #29380f)',\n },\n];\n\nconst whileDrag = {\n boxShadow: '0 22px 44px rgba(0, 0, 0, 0.5)',\n rotate: 3,\n scale: 1.1,\n};\nconst snap = { bounce: 0.1, type: 'spring', visualDuration: 0.2 } as const;\n\nexport class ReorderGrid extends Component {\n @tracked items = albums;\n\n setItems = (items: typeof albums) => {\n this.items = items;\n };\n\n cycle = () => {\n this.setItems([...this.items.slice(1), this.items[0]!]);\n };\n\n <template>\n <div class='ex no-select' {{on 'selectstart' preventSelect}}>\n <button\n type='button'\n class='replay'\n data-tour-reorder\n {{on 'click' this.cycle}}\n >Move first to last</button>\n <ReorderGroup\n class='covers'\n @values={{this.items}}\n @onReorder={{this.setItems}}\n @axis='xy'\n as |group|\n >\n {{#each this.items as |album|}}\n <ReorderItem\n class='cover'\n @group={{group}}\n @value={{album}}\n @style={{tileStyle album.wash}}\n @transition={{tuneObject 'grid' snap 'ReorderItem transition 1'}}\n @whileDrag={{tuneObject 'grid' whileDrag 'ReorderItem whileDrag 2'}}\n >\n <b>{{album.label}}</b>\n <small>{{album.year}}</small>\n </ReorderItem>\n {{/each}}\n </ReorderGroup>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n .replay {\n position: absolute;\n top: 14px;\n right: 14px;\n z-index: 3;\n border: 1px solid var(--line-strong);\n background: rgba(var(--bg-rgb), 0.72);\n backdrop-filter: blur(8px);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .replay:hover {\n color: var(--ink);\n }\n }\n\n .covers {\n list-style: none;\n margin: 0;\n padding: 0;\n touch-action: none;\n }\n\n .covers {\n width: min(88%, 300px);\n display: grid;\n grid-template-columns: 1fr 1fr 1fr;\n gap: 8px;\n }\n\n .cover {\n position: relative;\n aspect-ratio: 0.86;\n border-radius: 16px;\n display: flex;\n flex-direction: column;\n justify-content: flex-end;\n padding: 10px;\n color: #fff;\n cursor: grab;\n touch-action: none;\n box-shadow: 0 10px 22px\n rgba(var(--shadow-rgb), calc(0.32 * var(--shadow-a)));\n }\n\n /* a grip appears on hover: these rearrange, and nothing said so before you\n happened to try dragging one */\n .cover::after {\n content: '';\n position: absolute;\n top: 8px;\n right: 8px;\n width: 14px;\n height: 10px;\n opacity: 0;\n transform: translateY(-2px);\n transition:\n opacity 0.18s var(--ease),\n transform 0.18s var(--ease);\n background-image: radial-gradient(\n currentColor 1.1px,\n transparent 1.2px\n );\n background-size: 5px 5px;\n color: rgba(255, 255, 255, 0.85);\n filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.5));\n pointer-events: none;\n }\n\n @media (hover: hover) {\n .covers:hover .cover::after {\n opacity: 0.45;\n transform: translateY(0);\n }\n }\n\n @media (hover: hover) {\n .cover:hover::after {\n opacity: 1;\n }\n }\n\n @media (hover: hover) {\n .cover:hover {\n box-shadow: 0 14px 30px\n rgba(var(--shadow-rgb), calc(0.42 * var(--shadow-a)));\n }\n }\n\n .cover:active {\n cursor: grabbing;\n }\n\n .cover b {\n font-family: var(--font-display);\n font-size: 13px;\n letter-spacing: -0.03em;\n }\n\n .cover small {\n opacity: 0.7;\n font-size: 10px;\n }\n\n @container (max-width: 420px) {\n .covers {\n width: min(92%, 100%);\n max-width: 100%;\n }\n }\n\n @container (max-width: 280px) {\n .covers {\n gap: 6px;\n }\n }\n </style>\n </template>\n}\n\nfunction tileStyle(wash: string) {\n return { background: wash };\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneObject('grid', snap, 'ReorderItem transition 1');\ntuneObject('grid', whileDrag, 'ReorderItem whileDrag 2');\n\nexport class ReorderGridDemo extends GalleryDemo {\n static stage = ReorderGrid;\n}\n";
430
430
  //#endregion
431
431
  //#region ../choreo-gallery/realm/stages/grip.gts?raw
432
- var grip_default = "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport type { DragControls } from 'glimmer-motion';\nimport { createDragControls, motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion, tuneObject } from '../lib/tuning';\n\n/**\n * Seat twelve people. Drag them by the corner, because the card is also a form.\n *\n * This stage was a grey card with a grip, a \"pin\", and a scrolling log of\n * pointer events, under the instruction \"Hover the card. Press it. Then lift it\n * by the grip.\" It demonstrated the same API it demonstrates now and it failed\n * the plainest test there is: freeze every frame and nothing is lost, because\n * `dragSnapToOrigin` meant the drag had no consequence. The card ended where it\n * started, every time, on purpose. A demo of a gesture that cannot do anything\n * is a demo of decoration.\n *\n * A seating plan gives every part of the cluster a job it cannot be stripped\n * of:\n *\n * - **`dragListener=false` is a requirement, not a nicety.** Each guest card\n * carries a note field and a phone number. With `drag=true` you cannot put a\n * caret in that field or select that number — the element's own pointerdown\n * opens a drag and swallows the gesture. Turning the card's listener off and\n * starting the session from the corner tab is the ONLY way a draggable thing\n * can also be a form. That is the whole reason the controls object exists.\n * - **`onPanSessionStart` arms the tables.** It fires at pointerdown, BEFORE\n * the threshold that decides this is a drag, so every table with a free chair\n * lights the instant you take hold of someone — while you are still deciding\n * whether to move them. Nothing else in the drag surface fires that early,\n * and a demo that logs it to a list rather than doing something with it is\n * not showing you what it is for.\n * - **`dragSnapToOrigin` is the refusal.** Drop a guest on a full table, or on\n * nothing, and they fly back to the rail. The snap-back is the only \"no\" this\n * interface gives, and the flight is what makes it legible: frozen, a refused\n * drop and an untouched card are the same picture.\n * - **`onTap` and `onTapCancel` are the two ways a press ends.** Released on\n * the card, it opens the guest's details. Pressed, twitched and released off\n * it, nothing happens — which is the distinction a press handler has to make\n * by hand, doing an actual job rather than printing its own name.\n *\n * And there is a rule, so that the plan can be wrong: two of the guests do not\n * speak to each other. Seat them together and the table says so. That is the\n * difference between a board you filled in and a board you got right.\n */\n\n/**\n * The ride home. There are no coordinates to return to on a hit — a seated\n * guest leaves the rail entirely — so this spring is only ever seen on a\n * refusal, and it is tuned to read as one: quick, and slightly overshot, the\n * way something handed back is.\n */\nconst RETURN = {\n bounceDamping: 34,\n bounceStiffness: 520,\n power: 0.18,\n timeConstant: 210,\n} as const;\n\nconst SEAT_IN = { opacity: 0, scale: 0.86 };\nconst SEAT_HERE = { opacity: 1, scale: 1 };\nconst SEAT_SPRING = {\n bounce: 0.28,\n type: 'spring',\n visualDuration: 0.32,\n} as const;\n\ninterface Guest {\n /** ticked dietary pills, by id */\n diets: string[];\n id: string;\n name: string;\n /** anything the pills do not cover */\n note: string;\n phone: string;\n /** where they are sitting, or null for still on the rail */\n table: string | null;\n}\n\n/**\n * The pills. Checkboxes, drawn as chips, and the point of them is that they\n * are CLICKABLE on a card that also drags — under `drag=true` a press on one of\n * these opens a drag session instead of ticking anything.\n */\nconst DIETS = [\n { id: 'shellfish', label: 'no shellfish' },\n { id: 'veg', label: 'vegetarian' },\n { id: 'nuts', label: 'nut allergy' },\n { id: 'gluten', label: 'gluten free' },\n { id: 'dairy', label: 'no dairy' },\n { id: 'pork', label: 'no pork' },\n] as const;\n\nconst LABELS = new Map<string, string>(DIETS.map((d) => [d.id, d.label]));\n\nconst TABLES = [\n { id: 't1', name: 'Table one' },\n { id: 't2', name: 'Table two' },\n { id: 't3', name: 'Table three' },\n];\n\n/** four chairs each, twelve guests, so a full plan uses every seat */\nconst SEATS = 4;\n\n/** the two who do not speak. A plan can be complete and still be wrong */\nconst FEUD = ['nadia', 'raj'] as const;\n\nconst PEOPLE: Array<[string, string, string[], string]> = [\n ['nadia', 'Nadia Osei', ['shellfish'], '07700 900 118'],\n ['raj', 'Raj Chandra', ['veg'], '07700 900 214'],\n ['mira', 'Mira Halvorsen', [], '07700 900 337'],\n ['tom', 'Tom Beckett', ['nuts'], '07700 900 402'],\n ['ines', 'Inés Marchetti', [], '07700 900 551'],\n ['ola', 'Ola Adeyemi', ['dairy'], '07700 900 673'],\n ['fen', 'Fen Lightbody', [], '07700 900 719'],\n ['sam', 'Sam Okonkwo', ['gluten'], '07700 900 884'],\n ['bea', 'Bea Whitlock', [], '07700 900 905'],\n ['jun', 'Jun Watanabe', ['pork'], '07700 900 143'],\n ['edda', 'Edda Sørensen', [], '07700 900 266'],\n ['cal', 'Cal Brennan', ['gluten', 'dairy'], '07700 900 390'],\n];\n\nconst START: Guest[] = PEOPLE.map(([id, name, diets, phone]) => ({\n diets,\n id,\n name,\n note: '',\n phone,\n table: null,\n}));\n\nexport class Grip extends Component {\n tables = TABLES;\n seats = SEATS;\n\n diets = DIETS;\n\n @tracked guests: Guest[] = START.map((g) => ({ ...g, diets: [...g.diets] }));\n /** a hand is on somebody: every table with room says so */\n @tracked armed = false;\n /**\n * The zone the pointer is actually over, updated per drag frame.\n *\n * `armed` says where a guest COULD go; this says where they WOULD go. A\n * board that only shows the first makes you drop and find out — which is\n * fine once and tiresome twelve times, and it is the difference between a\n * drop target and a drop target you can aim at.\n */\n @tracked over: string | null = null;\n /** whose details are open, from a tap that landed */\n @tracked open: string | null = null;\n @tracked note =\n 'take someone by the corner tab — the free tables will say so';\n\n /**\n * One controls object per card, made on demand and kept.\n *\n * `createDragControls()` is a handle to ONE element's gesture, so a shared\n * one would start whichever card happened to register last. Keyed by guest\n * because the rail is a changing list and an index is not an identity.\n */\n private controls = new Map<string, DragControls>();\n\n controlsFor = (id: string) => {\n let c = this.controls.get(id);\n if (!c) {\n c = createDragControls();\n this.controls.set(id, c);\n }\n return c;\n };\n\n private tableEls = new Map<string, HTMLElement>();\n private railEl: HTMLElement | null = null;\n\n bindRail = modifier((el: HTMLElement) => {\n this.railEl = el;\n return () => {\n this.railEl = null;\n };\n });\n\n bindTable = modifier((el: HTMLElement, [id]: [string]) => {\n this.tableEls.set(id, el);\n return () => {\n this.tableEls.delete(id);\n };\n });\n\n get unseated() {\n return this.guests.filter((g) => g.table === null);\n }\n\n get left() {\n return this.unseated.length;\n }\n\n seatedAt = (table: string) => this.guests.filter((g) => g.table === table);\n\n roomAt = (table: string) => this.seatedAt(table).length < SEATS;\n\n /** both halves of the feud at one table is a plan that is finished and wrong */\n feudAt = (table: string) =>\n FEUD.every((id) =>\n this.guests.some((g) => g.id === id && g.table === table),\n );\n\n get anyFeud() {\n return TABLES.some((t) => this.feudAt(t.id));\n }\n\n get done() {\n return this.left === 0 && !this.anyFeud;\n }\n\n get tally() {\n if (this.done) {\n return 'everyone seated';\n }\n return this.left === 0 ? 'everyone seated, but…' : `${this.left} to seat`;\n }\n\n /**\n * Three things at once, in a fixed order of importance: where you ARE, where\n * you COULD go, and what is already wrong.\n *\n * The hover state comes first because it answers the question being asked\n * right now. A table that is both a legal target and the one under the\n * pointer should look like the one under the pointer — otherwise every table\n * looks the same at the moment the difference matters.\n */\n tableClass = (table: string) => {\n const classes = ['tp-table'];\n if (this.over === table) {\n classes.push(this.roomAt(table) ? 'is-over' : 'is-no');\n } else if (this.armed && this.roomAt(table)) {\n classes.push('is-live');\n }\n if (!this.roomAt(table)) {\n classes.push('is-full');\n }\n if (this.feudAt(table)) {\n classes.push('is-wrong');\n }\n return classes.join(' ');\n };\n\n cardClass = (id: string) =>\n this.open === id ? 'tp-card is-open' : 'tp-card';\n\n /**\n * A card in flight has to ride OVER the tables. The rail and the tables are\n * siblings in a grid, so without this the card slides underneath the thing\n * it is being carried to and disappears at the moment the drop matters most.\n * Set on the drag rather than always, so a resting rail does not sit above\n * everything for no reason.\n */\n liftClass = (id: string) => (this.carrying === id ? 'tp-lift' : '');\n\n /** the chair a guest is sitting in, and whether it is the one in flight */\n seatClass = (id: string) =>\n this.carrying === id ? 'tp-seat tp-lift' : 'tp-seat';\n\n /**\n * A getter, not a method. `class={{this.railClass}}` on a zero-argument\n * method renders the FUNCTION — Glimmer only invokes a value that is a\n * helper — and the attribute comes out as junk rather than as nothing, so the\n * element quietly stops matching its own selector. It cost a test that could\n * not find `.tp-rail` on a page that plainly had one.\n */\n get railClass() {\n if (this.over === 'rail') {\n return 'tp-rail is-over';\n }\n return this.armed && this.carrying ? 'tp-rail is-live' : 'tp-rail';\n }\n\n @tracked private carrying: string | null = null;\n\n hoist = (id: string) => {\n this.carrying = id;\n // stale from the last drag; re-measured on the first frame of this one,\n // by which point the card has already shrunk\n this.zones = null;\n };\n\n /**\n * The corner tab is not the draggable element — it is a button that starts\n * the draggable element's gesture, and the event is forwarded because the\n * session needs the real pointer that opened it rather than a synthetic one.\n */\n lift = (id: string, event: PointerEvent) => {\n this.controlsFor(id).start(event);\n };\n\n /** pointerdown, before the threshold: light every table with a chair free */\n session = () => {\n this.armed = true;\n this.note = 'the lit tables have room — the others are full';\n };\n\n /**\n * The last live pointer position, in VIEWPORT coordinates, kept per frame.\n *\n * Two wrong answers preceded this one and both are worth keeping, because\n * each looks obviously right until it is measured.\n *\n * The first read `info.point` in `onDragEnd` and tested it against each\n * table's rect. `point` is built from `pageX/pageY` and a rect is viewport\n * coordinates, so the two need reconciling — and the reconciliation is only a\n * scroll offset while nothing between them is transformed. Under a scaled\n * ancestor, which is what this stage sits inside during a crossing, they\n * differ by a factor as well, and the drop silently never lands.\n *\n * The second asked the CARD where it was at drag end, which is the better\n * question and the wrong moment: `dragSnapToOrigin` starts the return in the\n * same breath as `onDragEnd`, so the box being measured is already on its way\n * home. In the fixture it had travelled a third of the way back before the\n * handler read it.\n *\n * `clientX/clientY` off the last drag frame is in the same space as every\n * rect, needs no conversion, and is captured while the gesture is still the\n * truth rather than after it has been undone.\n */\n private lastAt: { x: number; y: number } | null = null;\n\n /**\n * The zones' boxes, measured ONCE per drag.\n *\n * Both halves of this matter and the stage was doing neither. Measuring four\n * elements on every drag frame interleaves layout reads with the drag's own\n * transform writes, which is the classic thrash: each read forces the style\n * the previous write invalidated. And assigning `over` unconditionally made\n * it a tracked write per frame, so the whole board — twelve cards, six pills\n * each, three tables — re-rendered sixty times a second to answer a question\n * whose answer changes perhaps four times in a drag.\n *\n * Together they cost about a frame a second. The design was right and the\n * implementation was a loop; this is the same design with the loop taken out.\n *\n * Once is enough because the board does not move while a card is being\n * carried: the columns are fixed proportions of the grid, so shrinking a card\n * in the rail cannot shift a table.\n */\n private zones: Array<{ id: string; rect: DOMRect }> | null = null;\n\n private measureZones() {\n const out: Array<{ id: string; rect: DOMRect }> = [];\n for (const t of TABLES) {\n const el = this.tableEls.get(t.id);\n if (el) {\n out.push({ id: t.id, rect: el.getBoundingClientRect() });\n }\n }\n if (this.railEl) {\n out.push({ id: 'rail', rect: this.railEl.getBoundingClientRect() });\n }\n this.zones = out;\n }\n\n track = (event: MouseEvent | PointerEvent | TouchEvent) => {\n const at = event as { clientX?: number; clientY?: number };\n if (typeof at.clientX !== 'number' || typeof at.clientY !== 'number') {\n return;\n }\n this.lastAt = { x: at.clientX, y: at.clientY };\n const next = this.zoneUnder(this.lastAt);\n // the guard is the whole fix: a tracked write per frame is a render per\n // frame, and this answer changes a handful of times in an entire drag\n if (next !== this.over) {\n this.over = next;\n }\n };\n\n /**\n * Which zone the pointer is over: a table, the list, or nothing.\n *\n * The list is a drop target too, and that is the interaction the first cut\n * was missing. Without it the only way to change your mind about a seated\n * guest is a small × on their chair, which is a different gesture for the\n * same idea — put this person somewhere else. Everything you can do by\n * dragging should be undoable by dragging.\n */\n private zoneUnder(at: { x: number; y: number } | null): string | null {\n if (!at) {\n return null;\n }\n if (!this.zones) {\n this.measureZones();\n }\n for (const z of this.zones ?? []) {\n const r = z.rect;\n if (\n at.x >= r.left &&\n at.x <= r.right &&\n at.y >= r.top &&\n at.y <= r.bottom\n ) {\n return z.id;\n }\n }\n return null;\n }\n\n drop = (guest: Guest, event?: MouseEvent | PointerEvent | TouchEvent) => {\n this.armed = false;\n this.carrying = null;\n const end = event as { clientX?: number; clientY?: number } | undefined;\n const at =\n typeof end?.clientX === 'number' && typeof end.clientY === 'number'\n ? { x: end.clientX, y: end.clientY }\n : this.lastAt;\n const zone = this.zoneUnder(at);\n this.lastAt = null;\n this.over = null;\n this.zones = null;\n\n if (!zone) {\n this.note = `${guest.name} came back — that was not a table`;\n return;\n }\n\n if (zone === 'rail') {\n if (guest.table === null) {\n this.note = `${guest.name} is still on the list`;\n return;\n }\n this.seat(guest, null);\n this.note = `${guest.name} is back on the list`;\n return;\n }\n\n const table = TABLES.find((t) => t.id === zone)!;\n if (guest.table === table.id) {\n this.note = `${guest.name} is already at ${table.name.toLowerCase()}`;\n return;\n }\n if (!this.roomAt(table.id)) {\n this.note = `${table.name} is full — ${guest.name} came back`;\n return;\n }\n this.seat(guest, table.id);\n this.note = this.feudAt(table.id)\n ? `${table.name}: those two do not speak`\n : `${guest.name} is at ${table.name.toLowerCase()}`;\n };\n\n private seat(guest: Guest, table: string | null) {\n this.guests = this.guests.map((g) =>\n g.id === guest.id ? { ...g, table } : g,\n );\n }\n\n /**\n * Released ON the card: open their details. It only ever OPENS.\n *\n * A tap that also closed would be a trap, because every control inside the\n * flap — six pills, a field — sits on the card, so clicking one of them also\n * registers a tap on the card and the panel would shut under your finger the\n * moment you used it. Closing is the button's job, and the button says so.\n */\n press = (guest: Guest) => {\n this.open = guest.id;\n };\n\n /**\n * Pressed, twitched, released OFF it. Nothing happens, and that is the whole\n * value of having the two callbacks: somebody who started a press on a card\n * and slid off it was beginning a drag and thought better of it. Opening an\n * editor at them would be answering a question they withdrew.\n */\n pressCancel = () => {};\n\n /** the explicit affordance for the same thing, because a tap is invisible */\n toggleEdit = (guest: Guest) => {\n this.open = this.open === guest.id ? null : guest.id;\n };\n\n /**\n * Keep a control's pointer away from the card's press gesture.\n *\n * `done` did not work, and the reason is the whole hazard of putting\n * interactive things inside a pressable thing. The button's click closed the\n * flap; the card's `onTap` then fired a frame later — taps resolve on the\n * frameloop, not in the handler — saw a closed card, and opened it again.\n * Every press of `done` was a close followed instantly by an open, which\n * looks exactly like a dead button.\n *\n * Stopping the gesture at pointerDOWN rather than at click is what matters:\n * by the time a click exists the press has already been registered. This is\n * on the button rather than solved in `press`, because the honest statement\n * is that a control is not part of the card's press surface — not that the\n * card should try to guess which of its own taps were meant for it.\n */\n swallow = (event: Event) => {\n event.stopPropagation();\n };\n\n toggleDiet = (guest: Guest, diet: string) => {\n this.guests = this.guests.map((g) =>\n g.id === guest.id\n ? {\n ...g,\n diets: g.diets.includes(diet)\n ? g.diets.filter((d) => d !== diet)\n : [...g.diets, diet],\n }\n : g,\n );\n };\n\n hasDiet = (guest: Guest, diet: string) => guest.diets.includes(diet);\n\n dietClass = (guest: Guest, diet: string) =>\n guest.diets.includes(diet) ? 'tp-pill is-on' : 'tp-pill';\n\n /** what the card says at rest: the ticked pills, then anything typed */\n summaryOf = (guest: Guest) => {\n const ticked = guest.diets.map((d) => LABELS.get(d) ?? d);\n if (guest.note.trim()) {\n ticked.push(guest.note.trim());\n }\n return ticked.join(' · ');\n };\n\n editLabel = (id: string) => (this.open === id ? 'done' : 'edit');\n\n setNote = (guest: Guest, event: Event) => {\n const note = (event.target as HTMLInputElement).value;\n this.guests = this.guests.map((g) =>\n g.id === guest.id ? { ...g, note } : g,\n );\n };\n\n isOpen = (id: string) => this.open === id;\n\n freeAt = (table: string) => {\n const free = SEATS - this.seatedAt(table).length;\n return free === 0 ? 'full' : `${free} free`;\n };\n\n reset = () => {\n this.guests = START.map((g) => ({ ...g, diets: [...g.diets] }));\n this.open = null;\n this.armed = false;\n this.note = 'take someone by the corner tab — the free tables will say so';\n };\n\n <template>\n <div class='ex tp-ex'>\n <header class='tp-head'>\n <b class='tp-tally {{if this.done \"is-done\" \"\"}}'>{{this.tally}}</b>\n <span class='tp-note'>{{this.note}}</span>\n <button type='button' class='chip' {{on 'click' this.reset}}>start over</button>\n </header>\n\n <div class='tp-board'>\n {{! The list is a drop target too. Everything you can do by dragging is\n undone by dragging: a seated guest carried back here goes on the\n list again, which is why there is no × on a chair. }}\n <ol\n class={{this.railClass}}\n aria-label='Guests to seat'\n {{this.bindRail}}\n >\n {{#each this.unseated key='id' as |guest|}}\n <li>\n {{! The card's OWN pointerdown is off. Everything inside it — the\n note field, the number — is ordinary content again, and the\n corner tab is the only thing that can lift it. }}\n <article\n class='{{this.cardClass guest.id}} {{this.liftClass guest.id}}'\n {{motion\n drag=true\n dragControls=(this.controlsFor guest.id)\n dragListener=false\n dragSnapToOrigin=true\n dragTransition=(tuneObject 'grip' RETURN 'dragTransition')\n onPanSessionStart=this.session\n onDragStart=(fn this.hoist guest.id)\n onDrag=this.track\n onDragEnd=(fn this.drop guest)\n onTap=(fn this.press guest)\n onTapCancel=this.pressCancel\n }}\n >\n {{! The grip, with a rule down its right-hand side. It is a\n different KIND of thing from everything beside it — the one\n place a press means \"carry me\" rather than \"use me\" — and\n six dots pressed against a name do not say that on their\n own. }}\n <button\n type='button'\n class='tp-grip'\n aria-label='Move {{guest.name}}'\n {{on 'pointerdown' (fn this.lift guest.id)}}\n >\n <span class='tp-dots' aria-hidden='true'>\n <i></i><i></i><i></i><i></i><i></i><i></i>\n </span>\n </button>\n\n <div class='tp-main'>\n <div class='tp-who'>\n <b>{{guest.name}}</b>\n <span class='tp-diet'>{{this.summaryOf guest}}</span>\n <button\n type='button'\n class='tp-edit'\n {{on 'pointerdown' this.swallow}}\n {{on 'click' (fn this.toggleEdit guest)}}\n >{{this.editLabel guest.id}}</button>\n </div>\n\n {{#if (this.isOpen guest.id)}}\n {{! Every control in here is the argument. Six checkboxes\n and a text field on a card that also drags: with\n `drag=true` a press on a pill opens a drag session\n instead of ticking it, and the field never sees a\n caret. `dragListener=false` is what hands the pointer\n back to them. }}\n <div class='tp-flap'>\n <div class='tp-pills'>\n {{#each this.diets key='id' as |diet|}}\n <button\n type='button'\n class={{this.dietClass guest diet.id}}\n role='checkbox'\n aria-checked={{if\n (this.hasDiet guest diet.id)\n 'true'\n 'false'\n }}\n {{on 'pointerdown' this.swallow}}\n {{on 'click' (fn this.toggleDiet guest diet.id)}}\n >{{diet.label}}</button>\n {{/each}}\n </div>\n\n <div class='tp-line'>\n <input\n class='tp-field'\n placeholder='anything else'\n value={{guest.note}}\n aria-label='Other notes for {{guest.name}}'\n {{on 'pointerdown' this.swallow}}\n {{on 'input' (fn this.setNote guest)}}\n />\n <span class='tp-phone'>{{guest.phone}}</span>\n </div>\n </div>\n {{/if}}\n </div>\n </article>\n </li>\n {{else}}\n <li class='tp-empty'>nobody left on the list</li>\n {{/each}}\n </ol>\n\n <div class='tp-tables'>\n {{#each this.tables key='id' as |table|}}\n <section\n class={{this.tableClass table.id}}\n {{this.bindTable table.id}}\n >\n <div class='tp-table-head'>\n <span>{{table.name}}</span>\n <small>{{this.freeAt table.id}}</small>\n </div>\n\n <ul class='tp-seats'>\n {{#each (this.seatedAt table.id) key='id' as |guest|}}\n {{! A chair is draggable too, and it is the whole pill rather\n than a tab on it — there is no form in here to protect, so\n there is nothing to take the pointer back from. The same\n element being a drag handle when it has no content of its\n own, and needing a separate one when it does, is the\n clearest way to say what `dragListener` is for. }}\n <li\n class={{this.seatClass guest.id}}\n {{motion\n drag=true\n dragSnapToOrigin=true\n dragTransition=(tuneObject 'grip' RETURN 'dragTransition')\n initial=SEAT_IN\n animate=SEAT_HERE\n transition=(tuneMotion 'grip' SEAT_SPRING 'SEAT_SPRING')\n onPanSessionStart=this.session\n onDragStart=(fn this.hoist guest.id)\n onDrag=this.track\n onDragEnd=(fn this.drop guest)\n }}\n >\n <span>{{guest.name}}</span>\n </li>\n {{/each}}\n </ul>\n\n {{#if (this.feudAt table.id)}}\n <p class='tp-wrong'>Nadia and Raj do not speak.</p>\n {{/if}}\n </section>\n {{/each}}\n </div>\n </div>\n </div>\n <style scoped>\n .chip {\n border: 1px solid var(--line);\n background: transparent;\n color: var(--ink-dim);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n .chip.is-on {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n\n @media (hover: hover) {\n .chip:hover {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n }\n\n .chip.is-on {\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.35);\n color: var(--ember-hot);\n }\n\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n @media (max-width: 720px) {\n .chip {\n padding: 9px 14px;\n }\n }\n\n /* ---- Table Plan: seat twelve, by the corner tab ---- */\n\n .tp-ex {\n display: flex;\n flex-direction: column;\n gap: 10px;\n width: min(760px, 100%);\n }\n\n .tp-head {\n align-items: baseline;\n display: flex;\n gap: 10px;\n }\n\n .tp-tally {\n color: var(--ink);\n font-family: var(--font-mono);\n font-size: 12px;\n letter-spacing: 0.04em;\n white-space: nowrap;\n }\n\n .tp-tally.is-done {\n color: var(--copper-ink);\n }\n\n .tp-note {\n color: var(--ink-faint);\n flex: 1;\n font-size: 11px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .tp-board {\n display: grid;\n flex: 1;\n gap: 12px;\n grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);\n min-height: 0;\n }\n\n .tp-rail.is-live {\n background: rgba(230, 163, 90, 0.05);\n box-shadow: inset 0 0 0 1px rgba(230, 163, 90, 0.3);\n }\n\n .tp-rail.is-over {\n background: rgba(230, 163, 90, 0.12);\n box-shadow: inset 0 0 0 1px var(--copper);\n }\n\n .tp-rail {\n border-radius: 8px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n list-style: none;\n margin: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 0;\n scrollbar-gutter: stable;\n scrollbar-width: thin;\n }\n\n .tp-empty {\n color: var(--ink-faint);\n font-size: 11px;\n padding: 6px 2px;\n }\n\n /* The card: a grip column on the left, everything that keeps its own pointer on\n the right, and a rule between them. `touch-action: none` so a phone does not\n read the lift as a scroll. */\n .tp-card {\n align-items: stretch;\n background: var(--bg-elev);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: grid;\n grid-template-columns: 26px minmax(0, 1fr);\n overflow: hidden;\n position: relative;\n touch-action: none;\n }\n\n .tp-card.is-open {\n border-color: var(--line-strong);\n }\n\n /* Carried: over the tables, not under them. The rail and the tables are\n siblings in a grid, so a card with no z-index of its own slides beneath the\n thing it is being carried toward and vanishes at the moment the drop matters\n most. The shadow is the rest of the sentence — it says the card has left the\n list rather than merely moved along it.\n\n And it SHRINKS. A full-width card in flight covers the tables it is being\n aimed at, so the one thing you need to see while dragging is the thing the\n drag is hiding. Only the width is animated, deliberately: the row keeps its\n height, so nothing below it reflows and the list you are dragging out of\n stays still. */\n .tp-card.tp-lift {\n border-color: var(--line-strong);\n box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);\n cursor: grabbing;\n max-width: 190px;\n /* NOT transitioned. `max-width` is a layout property, so animating it makes\n the browser reflow the card — and the rail it sits in — on every frame of\n the transition, while the drag is already writing a transform to the same\n element each frame. The shrink is instant instead: at the moment you pick\n something up, an instant change reads as the thing becoming portable, and\n an animated one reads as lag. */\n z-index: 5;\n }\n\n /* the summary and the button go with it: a pill in flight is a name and a grip\n and nothing else */\n .tp-card.tp-lift .tp-diet,\n .tp-card.tp-lift .tp-edit,\n .tp-card.tp-lift .tp-flap {\n display: none;\n }\n\n /* and the rail itself has to stop clipping, or the card is cut off at its edge\n the moment it leaves */\n .tp-rail:has(.tp-lift) {\n overflow: visible;\n }\n\n /* The grip, and the rule down its right-hand side.\n This is the only press on the card that means \"carry me\" rather than \"use\n me\", and six dots pressed against a name do not say that. The line is doing\n real work: it marks where the draggable part stops and the form starts, which\n is precisely the boundary `dragListener=false` draws in the code. */\n .tp-grip {\n align-items: center;\n background: rgba(var(--ink-rgb), 0.04);\n border: 0;\n border-right: 1px solid var(--line);\n cursor: grab;\n display: grid;\n padding: 0;\n place-items: center;\n width: 26px;\n }\n\n .tp-grip:hover {\n background: rgba(var(--ink-rgb), 0.08);\n }\n\n .tp-grip:active {\n cursor: grabbing;\n }\n\n .tp-dots {\n display: grid;\n gap: 2px;\n grid-template-columns: repeat(2, 3px);\n }\n\n .tp-dots i {\n background: var(--ink-faint);\n border-radius: 50%;\n height: 3px;\n opacity: 0.75;\n width: 3px;\n }\n\n .tp-grip:hover .tp-dots i {\n opacity: 1;\n }\n\n .tp-main {\n display: flex;\n flex-direction: column;\n gap: 6px;\n min-width: 0;\n padding: 7px 8px;\n }\n\n .tp-who {\n align-items: baseline;\n display: flex;\n gap: 8px;\n min-width: 0;\n }\n\n .tp-who b {\n flex: none;\n font-size: 12.5px;\n }\n\n .tp-diet {\n color: var(--copper-ink);\n flex: 1;\n font-family: var(--font-mono);\n font-size: 10px;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n /* The visible way in. A tap on the card opens it too, but a tap is something\n you have to already know about. */\n .tp-edit {\n background: rgba(var(--ink-rgb), 0.07);\n border: 0;\n border-radius: 5px;\n color: var(--ink-faint);\n cursor: pointer;\n flex: none;\n font-family: var(--font-mono);\n font-size: 9.5px;\n letter-spacing: 0.08em;\n padding: 3px 7px;\n }\n\n .tp-edit:hover {\n color: var(--ink);\n }\n\n /* The half of the card that could not exist under `drag=true`: six checkboxes\n and a field, all of them wanting the pointer the drag would have taken. */\n .tp-flap {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .tp-pills {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n }\n\n /* Fixed size, at every stage width. These are checkboxes: a checkbox that grows\n and shrinks with the column it happens to be in reads as a different control\n each time, and the eye stops trusting the tick. */\n .tp-pill {\n background: rgba(var(--ink-rgb), 0.06);\n border: 1px solid transparent;\n border-radius: 999px;\n color: var(--ink-faint);\n cursor: pointer;\n font-family: var(--font-mono);\n font-size: 10px;\n line-height: 1.4;\n padding: 2px 8px;\n transition:\n background 140ms var(--ease),\n border-color 140ms var(--ease),\n color 140ms var(--ease);\n white-space: nowrap;\n }\n\n .tp-pill:hover {\n color: var(--ink);\n }\n\n .tp-pill.is-on {\n background: rgba(230, 163, 90, 0.16);\n border-color: rgba(230, 163, 90, 0.45);\n color: var(--copper-ink);\n }\n\n .tp-line {\n align-items: center;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .tp-field {\n background: rgba(var(--ink-rgb), 0.06);\n border: 1px solid var(--line);\n border-radius: 5px;\n color: var(--ink);\n flex: 1;\n font-size: 11px;\n min-width: 120px;\n padding: 3px 7px;\n }\n\n .tp-field:focus {\n border-color: var(--line-strong);\n outline: none;\n }\n\n .tp-phone {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 10.5px;\n user-select: text;\n }\n\n .tp-tables {\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n scrollbar-width: thin;\n }\n\n /* A table is a drop target and it has four things to say, in order of how\n urgently they are wanted: this is where it would land (`is-over`), this is\n somewhere it COULD land (`is-live`), this one would refuse you (`is-no`),\n and this one is already wrong (`is-wrong`).\n The hover state wins over the merely-available one because it answers the\n question actually being asked. Three identical lit boxes tell you nothing at\n the moment you are choosing between them. */\n .tp-table {\n background: rgba(var(--ink-rgb), 0.03);\n border: 1px dashed var(--line-strong);\n border-radius: 8px;\n padding: 7px 9px;\n transition:\n background 160ms var(--ease),\n border-color 160ms var(--ease);\n }\n\n .tp-table.is-live {\n background: rgba(230, 163, 90, 0.08);\n border-color: rgba(230, 163, 90, 0.4);\n }\n\n .tp-table.is-over {\n background: rgba(230, 163, 90, 0.2);\n border-color: var(--copper);\n border-style: solid;\n }\n\n /* Refusing in advance. A full table under the pointer says no BEFORE you let\n go, which is the difference between a rule and a punishment: the snap-back\n still happens, but nobody was surprised by it. */\n .tp-table.is-no {\n background: rgba(255, 59, 31, 0.1);\n border-color: rgba(255, 59, 31, 0.5);\n border-style: solid;\n opacity: 1;\n }\n\n .tp-table.is-full {\n opacity: 0.55;\n }\n\n .tp-table.is-wrong {\n background: rgba(255, 59, 31, 0.08);\n border-color: rgba(255, 59, 31, 0.45);\n border-style: solid;\n }\n\n .tp-table-head {\n color: var(--ink-dim);\n display: flex;\n font-family: var(--font-mono);\n font-size: 10px;\n justify-content: space-between;\n letter-spacing: 0.06em;\n margin-bottom: 5px;\n }\n\n .tp-table-head small {\n color: var(--ink-faint);\n }\n\n .tp-seats {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n list-style: none;\n margin: 0;\n min-height: 22px;\n padding: 0;\n }\n\n .tp-seat {\n align-items: center;\n background: var(--bg-elev);\n border: 1px solid var(--line);\n border-radius: 999px;\n cursor: grab;\n display: inline-flex;\n font-size: 11.5px;\n padding: 3px 10px;\n touch-action: none;\n }\n\n .tp-seat:active {\n cursor: grabbing;\n }\n\n .tp-seat.tp-lift {\n border-color: var(--line-strong);\n box-shadow: 0 10px 22px rgba(0, 0, 0, 0.3);\n position: relative;\n z-index: 5;\n }\n\n .tp-wrong {\n color: var(--ember);\n font-family: var(--font-mono);\n font-size: 10px;\n margin: 5px 0 0;\n }\n @container platter (max-width: 520px) {\n .tp-board {\n grid-template-columns: minmax(0, 1fr);\n }\n }\n </style>\n </template>\n}\n\nexport default Grip;\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneObject('grip', RETURN, 'dragTransition');\ntuneMotion('grip', SEAT_SPRING, 'SEAT_SPRING');\n\nexport class GripDemo extends GalleryDemo {\n static stage = Grip;\n}\n";
432
+ var grip_default = "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport type { DragControls } from 'glimmer-motion';\nimport { createDragControls, motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion, tuneObject } from '../lib/tuning';\n\n/**\n * Seat twelve people. Drag them by the corner, because the card is also a form.\n *\n * This stage was a grey card with a grip, a \"pin\", and a scrolling log of\n * pointer events, under the instruction \"Hover the card. Press it. Then lift it\n * by the grip.\" It demonstrated the same API it demonstrates now and it failed\n * the plainest test there is: freeze every frame and nothing is lost, because\n * `dragSnapToOrigin` meant the drag had no consequence. The card ended where it\n * started, every time, on purpose. A demo of a gesture that cannot do anything\n * is a demo of decoration.\n *\n * A seating plan gives every part of the cluster a job it cannot be stripped\n * of:\n *\n * - **`dragListener=false` is a requirement, not a nicety.** Each guest card\n * carries a note field and a phone number. With `drag=true` you cannot put a\n * caret in that field or select that number — the element's own pointerdown\n * opens a drag and swallows the gesture. Turning the card's listener off and\n * starting the session from the corner tab is the ONLY way a draggable thing\n * can also be a form. That is the whole reason the controls object exists.\n * - **`onPanSessionStart` arms the tables.** It fires at pointerdown, BEFORE\n * the threshold that decides this is a drag, so every table with a free chair\n * lights the instant you take hold of someone — while you are still deciding\n * whether to move them. Nothing else in the drag surface fires that early,\n * and a demo that logs it to a list rather than doing something with it is\n * not showing you what it is for.\n * - **`dragSnapToOrigin` is the refusal.** Drop a guest on a full table, or on\n * nothing, and they fly back to the rail. The snap-back is the only \"no\" this\n * interface gives, and the flight is what makes it legible: frozen, a refused\n * drop and an untouched card are the same picture.\n * - **`onTap` and `onTapCancel` are the two ways a press ends.** Released on\n * the card, it opens the guest's details. Pressed, twitched and released off\n * it, nothing happens — which is the distinction a press handler has to make\n * by hand, doing an actual job rather than printing its own name.\n *\n * And there is a rule, so that the plan can be wrong: two of the guests do not\n * speak to each other. Seat them together and the table says so. That is the\n * difference between a board you filled in and a board you got right.\n */\n\n/**\n * The ride home. There are no coordinates to return to on a hit — a seated\n * guest leaves the rail entirely — so this spring is only ever seen on a\n * refusal, and it is tuned to read as one: quick, and slightly overshot, the\n * way something handed back is.\n */\nconst RETURN = {\n bounceDamping: 34,\n bounceStiffness: 520,\n power: 0.18,\n timeConstant: 210,\n} as const;\n\nconst SEAT_IN = { opacity: 0, scale: 0.86 };\nconst SEAT_HERE = { opacity: 1, scale: 1 };\nconst SEAT_SPRING = {\n bounce: 0.28,\n type: 'spring',\n visualDuration: 0.32,\n} as const;\n\ninterface Guest {\n /** ticked dietary pills, by id */\n diets: string[];\n id: string;\n name: string;\n /** anything the pills do not cover */\n note: string;\n phone: string;\n /** where they are sitting, or null for still on the rail */\n table: string | null;\n}\n\n/**\n * The pills. Checkboxes, drawn as chips, and the point of them is that they\n * are CLICKABLE on a card that also drags — under `drag=true` a press on one of\n * these opens a drag session instead of ticking anything.\n */\nconst DIETS = [\n { id: 'shellfish', label: 'no shellfish' },\n { id: 'veg', label: 'vegetarian' },\n { id: 'nuts', label: 'nut allergy' },\n { id: 'gluten', label: 'gluten free' },\n { id: 'dairy', label: 'no dairy' },\n { id: 'pork', label: 'no pork' },\n] as const;\n\nconst LABELS = new Map<string, string>(DIETS.map((d) => [d.id, d.label]));\n\nconst TABLES = [\n { id: 't1', name: 'Table one' },\n { id: 't2', name: 'Table two' },\n { id: 't3', name: 'Table three' },\n];\n\n/** four chairs each, twelve guests, so a full plan uses every seat */\nconst SEATS = 4;\n\n/** the two who do not speak. A plan can be complete and still be wrong */\nconst FEUD = ['nadia', 'raj'] as const;\n\nconst PEOPLE: Array<[string, string, string[], string]> = [\n ['nadia', 'Nadia Osei', ['shellfish'], '07700 900 118'],\n ['raj', 'Raj Chandra', ['veg'], '07700 900 214'],\n ['mira', 'Mira Halvorsen', [], '07700 900 337'],\n ['tom', 'Tom Beckett', ['nuts'], '07700 900 402'],\n ['ines', 'Inés Marchetti', [], '07700 900 551'],\n ['ola', 'Ola Adeyemi', ['dairy'], '07700 900 673'],\n ['fen', 'Fen Lightbody', [], '07700 900 719'],\n ['sam', 'Sam Okonkwo', ['gluten'], '07700 900 884'],\n ['bea', 'Bea Whitlock', [], '07700 900 905'],\n ['jun', 'Jun Watanabe', ['pork'], '07700 900 143'],\n ['edda', 'Edda Sørensen', [], '07700 900 266'],\n ['cal', 'Cal Brennan', ['gluten', 'dairy'], '07700 900 390'],\n];\n\nconst START: Guest[] = PEOPLE.map(([id, name, diets, phone]) => ({\n diets,\n id,\n name,\n note: '',\n phone,\n table: null,\n}));\n\nexport class Grip extends Component {\n tables = TABLES;\n seats = SEATS;\n\n diets = DIETS;\n\n @tracked guests: Guest[] = START.map((g) => ({ ...g, diets: [...g.diets] }));\n /** a hand is on somebody: every table with room says so */\n @tracked armed = false;\n /**\n * The zone the pointer is actually over, updated per drag frame.\n *\n * `armed` says where a guest COULD go; this says where they WOULD go. A\n * board that only shows the first makes you drop and find out — which is\n * fine once and tiresome twelve times, and it is the difference between a\n * drop target and a drop target you can aim at.\n */\n @tracked over: string | null = null;\n /** whose details are open, from a tap that landed */\n @tracked open: string | null = null;\n @tracked note =\n 'take someone by the corner tab — the free tables will say so';\n\n /**\n * One controls object per card, made on demand and kept.\n *\n * `createDragControls()` is a handle to ONE element's gesture, so a shared\n * one would start whichever card happened to register last. Keyed by guest\n * because the rail is a changing list and an index is not an identity.\n */\n private controls = new Map<string, DragControls>();\n\n controlsFor = (id: string) => {\n let c = this.controls.get(id);\n if (!c) {\n c = createDragControls();\n this.controls.set(id, c);\n }\n return c;\n };\n\n private tableEls = new Map<string, HTMLElement>();\n private railEl: HTMLElement | null = null;\n\n bindRail = modifier((el: HTMLElement) => {\n this.railEl = el;\n return () => {\n this.railEl = null;\n };\n });\n\n bindTable = modifier((el: HTMLElement, [id]: [string]) => {\n this.tableEls.set(id, el);\n return () => {\n this.tableEls.delete(id);\n };\n });\n\n get unseated() {\n return this.guests.filter((g) => g.table === null);\n }\n\n get left() {\n return this.unseated.length;\n }\n\n seatedAt = (table: string) => this.guests.filter((g) => g.table === table);\n\n roomAt = (table: string) => this.seatedAt(table).length < SEATS;\n\n /** both halves of the feud at one table is a plan that is finished and wrong */\n feudAt = (table: string) =>\n FEUD.every((id) =>\n this.guests.some((g) => g.id === id && g.table === table),\n );\n\n get anyFeud() {\n return TABLES.some((t) => this.feudAt(t.id));\n }\n\n get done() {\n return this.left === 0 && !this.anyFeud;\n }\n\n get tally() {\n if (this.done) {\n return 'everyone seated';\n }\n return this.left === 0 ? 'everyone seated, but…' : `${this.left} to seat`;\n }\n\n /**\n * Three things at once, in a fixed order of importance: where you ARE, where\n * you COULD go, and what is already wrong.\n *\n * The hover state comes first because it answers the question being asked\n * right now. A table that is both a legal target and the one under the\n * pointer should look like the one under the pointer — otherwise every table\n * looks the same at the moment the difference matters.\n */\n tableClass = (table: string) => {\n const classes = ['tp-table'];\n if (this.over === table) {\n classes.push(this.roomAt(table) ? 'is-over' : 'is-no');\n } else if (this.armed && this.roomAt(table)) {\n classes.push('is-live');\n }\n if (!this.roomAt(table)) {\n classes.push('is-full');\n }\n if (this.feudAt(table)) {\n classes.push('is-wrong');\n }\n return classes.join(' ');\n };\n\n cardClass = (id: string) =>\n this.open === id ? 'tp-card is-open' : 'tp-card';\n\n /**\n * A card in flight has to ride OVER the tables. The rail and the tables are\n * siblings in a grid, so without this the card slides underneath the thing\n * it is being carried to and disappears at the moment the drop matters most.\n * Set on the drag rather than always, so a resting rail does not sit above\n * everything for no reason.\n */\n liftClass = (id: string) => (this.carrying === id ? 'tp-lift' : '');\n\n /** the chair a guest is sitting in, and whether it is the one in flight */\n seatClass = (id: string) =>\n this.carrying === id ? 'tp-seat tp-lift' : 'tp-seat';\n\n /**\n * A getter, not a method. `class={{this.railClass}}` on a zero-argument\n * method renders the FUNCTION — Glimmer only invokes a value that is a\n * helper — and the attribute comes out as junk rather than as nothing, so the\n * element quietly stops matching its own selector. It cost a test that could\n * not find `.tp-rail` on a page that plainly had one.\n */\n get railClass() {\n if (this.over === 'rail') {\n return 'tp-rail is-over';\n }\n return this.armed && this.carrying ? 'tp-rail is-live' : 'tp-rail';\n }\n\n @tracked private carrying: string | null = null;\n\n hoist = (id: string) => {\n this.carrying = id;\n // stale from the last drag; re-measured on the first frame of this one,\n // by which point the card has already shrunk\n this.zones = null;\n };\n\n /**\n * The corner tab is not the draggable element — it is a button that starts\n * the draggable element's gesture, and the event is forwarded because the\n * session needs the real pointer that opened it rather than a synthetic one.\n */\n lift = (id: string, event: PointerEvent) => {\n this.controlsFor(id).start(event);\n };\n\n /** pointerdown, before the threshold: light every table with a chair free */\n session = () => {\n this.armed = true;\n this.note = 'the lit tables have room — the others are full';\n };\n\n /**\n * The last live pointer position, in VIEWPORT coordinates, kept per frame.\n *\n * Two wrong answers preceded this one and both are worth keeping, because\n * each looks obviously right until it is measured.\n *\n * The first read `info.point` in `onDragEnd` and tested it against each\n * table's rect. `point` is built from `pageX/pageY` and a rect is viewport\n * coordinates, so the two need reconciling — and the reconciliation is only a\n * scroll offset while nothing between them is transformed. Under a scaled\n * ancestor, which is what this stage sits inside during a crossing, they\n * differ by a factor as well, and the drop silently never lands.\n *\n * The second asked the CARD where it was at drag end, which is the better\n * question and the wrong moment: `dragSnapToOrigin` starts the return in the\n * same breath as `onDragEnd`, so the box being measured is already on its way\n * home. In the fixture it had travelled a third of the way back before the\n * handler read it.\n *\n * `clientX/clientY` off the last drag frame is in the same space as every\n * rect, needs no conversion, and is captured while the gesture is still the\n * truth rather than after it has been undone.\n */\n private lastAt: { x: number; y: number } | null = null;\n\n /**\n * The zones' boxes, measured ONCE per drag.\n *\n * Both halves of this matter and the stage was doing neither. Measuring four\n * elements on every drag frame interleaves layout reads with the drag's own\n * transform writes, which is the classic thrash: each read forces the style\n * the previous write invalidated. And assigning `over` unconditionally made\n * it a tracked write per frame, so the whole board — twelve cards, six pills\n * each, three tables — re-rendered sixty times a second to answer a question\n * whose answer changes perhaps four times in a drag.\n *\n * Together they cost about a frame a second. The design was right and the\n * implementation was a loop; this is the same design with the loop taken out.\n *\n * Once is enough because the board does not move while a card is being\n * carried: the columns are fixed proportions of the grid, so shrinking a card\n * in the rail cannot shift a table.\n */\n private zones: Array<{ id: string; rect: DOMRect }> | null = null;\n\n private measureZones() {\n const out: Array<{ id: string; rect: DOMRect }> = [];\n for (const t of TABLES) {\n const el = this.tableEls.get(t.id);\n if (el) {\n out.push({ id: t.id, rect: el.getBoundingClientRect() });\n }\n }\n if (this.railEl) {\n out.push({ id: 'rail', rect: this.railEl.getBoundingClientRect() });\n }\n this.zones = out;\n }\n\n track = (event: MouseEvent | PointerEvent | TouchEvent) => {\n const at = event as { clientX?: number; clientY?: number };\n if (typeof at.clientX !== 'number' || typeof at.clientY !== 'number') {\n return;\n }\n this.lastAt = { x: at.clientX, y: at.clientY };\n const next = this.zoneUnder(this.lastAt);\n // the guard is the whole fix: a tracked write per frame is a render per\n // frame, and this answer changes a handful of times in an entire drag\n if (next !== this.over) {\n this.over = next;\n }\n };\n\n /**\n * Which zone the pointer is over: a table, the list, or nothing.\n *\n * The list is a drop target too, and that is the interaction the first cut\n * was missing. Without it the only way to change your mind about a seated\n * guest is a small × on their chair, which is a different gesture for the\n * same idea — put this person somewhere else. Everything you can do by\n * dragging should be undoable by dragging.\n */\n private zoneUnder(at: { x: number; y: number } | null): string | null {\n if (!at) {\n return null;\n }\n if (!this.zones) {\n this.measureZones();\n }\n for (const z of this.zones ?? []) {\n const r = z.rect;\n if (\n at.x >= r.left &&\n at.x <= r.right &&\n at.y >= r.top &&\n at.y <= r.bottom\n ) {\n return z.id;\n }\n }\n return null;\n }\n\n drop = (guest: Guest, event?: MouseEvent | PointerEvent | TouchEvent) => {\n this.armed = false;\n this.carrying = null;\n const end = event as { clientX?: number; clientY?: number } | undefined;\n const at =\n typeof end?.clientX === 'number' && typeof end.clientY === 'number'\n ? { x: end.clientX, y: end.clientY }\n : this.lastAt;\n const zone = this.zoneUnder(at);\n this.lastAt = null;\n this.over = null;\n this.zones = null;\n\n if (!zone) {\n this.note = `${guest.name} came back — that was not a table`;\n return;\n }\n\n if (zone === 'rail') {\n if (guest.table === null) {\n this.note = `${guest.name} is still on the list`;\n return;\n }\n this.seat(guest, null);\n this.note = `${guest.name} is back on the list`;\n return;\n }\n\n const table = TABLES.find((t) => t.id === zone)!;\n if (guest.table === table.id) {\n this.note = `${guest.name} is already at ${table.name.toLowerCase()}`;\n return;\n }\n if (!this.roomAt(table.id)) {\n this.note = `${table.name} is full — ${guest.name} came back`;\n return;\n }\n this.seat(guest, table.id);\n this.note = this.feudAt(table.id)\n ? `${table.name}: those two do not speak`\n : `${guest.name} is at ${table.name.toLowerCase()}`;\n };\n\n private seat(guest: Guest, table: string | null) {\n this.guests = this.guests.map((g) =>\n g.id === guest.id ? { ...g, table } : g,\n );\n }\n\n /**\n * Released ON the card: open their details. It only ever OPENS.\n *\n * A tap that also closed would be a trap, because every control inside the\n * flap — six pills, a field — sits on the card, so clicking one of them also\n * registers a tap on the card and the panel would shut under your finger the\n * moment you used it. Closing is the button's job, and the button says so.\n */\n press = (guest: Guest) => {\n this.open = guest.id;\n };\n\n /**\n * Pressed, twitched, released OFF it. Nothing happens, and that is the whole\n * value of having the two callbacks: somebody who started a press on a card\n * and slid off it was beginning a drag and thought better of it. Opening an\n * editor at them would be answering a question they withdrew.\n */\n pressCancel = () => {};\n\n /** the explicit affordance for the same thing, because a tap is invisible */\n toggleEdit = (guest: Guest) => {\n this.open = this.open === guest.id ? null : guest.id;\n };\n\n /**\n * Keep a control's pointer away from the card's press gesture.\n *\n * `done` did not work, and the reason is the whole hazard of putting\n * interactive things inside a pressable thing. The button's click closed the\n * flap; the card's `onTap` then fired a frame later — taps resolve on the\n * frameloop, not in the handler — saw a closed card, and opened it again.\n * Every press of `done` was a close followed instantly by an open, which\n * looks exactly like a dead button.\n *\n * Stopping the gesture at pointerDOWN rather than at click is what matters:\n * by the time a click exists the press has already been registered. This is\n * on the button rather than solved in `press`, because the honest statement\n * is that a control is not part of the card's press surface — not that the\n * card should try to guess which of its own taps were meant for it.\n */\n swallow = (event: Event) => {\n event.stopPropagation();\n };\n\n toggleDiet = (guest: Guest, diet: string) => {\n this.guests = this.guests.map((g) =>\n g.id === guest.id\n ? {\n ...g,\n diets: g.diets.includes(diet)\n ? g.diets.filter((d) => d !== diet)\n : [...g.diets, diet],\n }\n : g,\n );\n };\n\n hasDiet = (guest: Guest, diet: string) => guest.diets.includes(diet);\n\n dietClass = (guest: Guest, diet: string) =>\n guest.diets.includes(diet) ? 'tp-pill is-on' : 'tp-pill';\n\n /** what the card says at rest: the ticked pills, then anything typed */\n summaryOf = (guest: Guest) => {\n const ticked = guest.diets.map((d) => LABELS.get(d) ?? d);\n if (guest.note.trim()) {\n ticked.push(guest.note.trim());\n }\n return ticked.join(' · ');\n };\n\n editLabel = (id: string) => (this.open === id ? 'done' : 'edit');\n\n setNote = (guest: Guest, event: Event) => {\n const note = (event.target as HTMLInputElement).value;\n this.guests = this.guests.map((g) =>\n g.id === guest.id ? { ...g, note } : g,\n );\n };\n\n isOpen = (id: string) => this.open === id;\n\n freeAt = (table: string) => {\n const free = SEATS - this.seatedAt(table).length;\n return free === 0 ? 'full' : `${free} free`;\n };\n\n reset = () => {\n this.guests = START.map((g) => ({ ...g, diets: [...g.diets] }));\n this.open = null;\n this.armed = false;\n this.note = 'take someone by the corner tab — the free tables will say so';\n };\n\n <template>\n <div class='ex tp-ex'>\n <header class='tp-head'>\n <b class='tp-tally {{if this.done \"is-done\" \"\"}}'>{{this.tally}}</b>\n <span class='tp-note'>{{this.note}}</span>\n <button type='button' class='chip' {{on 'click' this.reset}}>start over</button>\n </header>\n\n <div class='tp-board'>\n {{! The list is a drop target too. Everything you can do by dragging is\n undone by dragging: a seated guest carried back here goes on the\n list again, which is why there is no × on a chair. }}\n <ol\n class={{this.railClass}}\n aria-label='Guests to seat'\n {{this.bindRail}}\n >\n {{#each this.unseated key='id' as |guest|}}\n <li>\n {{! The card's OWN pointerdown is off. Everything inside it — the\n note field, the number — is ordinary content again, and the\n corner tab is the only thing that can lift it. }}\n <article\n class='{{this.cardClass guest.id}} {{this.liftClass guest.id}}'\n {{motion\n drag=true\n dragControls=(this.controlsFor guest.id)\n dragListener=false\n dragSnapToOrigin=true\n dragTransition=(tuneObject 'grip' RETURN 'dragTransition')\n onPanSessionStart=this.session\n onDragStart=(fn this.hoist guest.id)\n onDrag=this.track\n onDragEnd=(fn this.drop guest)\n onTap=(fn this.press guest)\n onTapCancel=this.pressCancel\n }}\n >\n {{! The grip, with a rule down its right-hand side. It is a\n different KIND of thing from everything beside it — the one\n place a press means \"carry me\" rather than \"use me\" — and\n six dots pressed against a name do not say that on their\n own. }}\n <button\n type='button'\n class='tp-grip'\n aria-label='Move {{guest.name}}'\n {{on 'pointerdown' (fn this.lift guest.id)}}\n >\n <span class='tp-dots' aria-hidden='true'>\n <i></i><i></i><i></i><i></i><i></i><i></i>\n </span>\n </button>\n\n <div class='tp-main'>\n <div class='tp-who'>\n <b>{{guest.name}}</b>\n <span class='tp-diet'>{{this.summaryOf guest}}</span>\n <button\n type='button'\n class='tp-edit'\n {{on 'pointerdown' this.swallow}}\n {{on 'click' (fn this.toggleEdit guest)}}\n >{{this.editLabel guest.id}}</button>\n </div>\n\n {{#if (this.isOpen guest.id)}}\n {{! Every control in here is the argument. Six checkboxes\n and a text field on a card that also drags: with\n `drag=true` a press on a pill opens a drag session\n instead of ticking it, and the field never sees a\n caret. `dragListener=false` is what hands the pointer\n back to them. }}\n <div class='tp-flap'>\n <div class='tp-pills'>\n {{#each this.diets key='id' as |diet|}}\n <button\n type='button'\n class={{this.dietClass guest diet.id}}\n role='checkbox'\n aria-checked={{if\n (this.hasDiet guest diet.id)\n 'true'\n 'false'\n }}\n {{on 'pointerdown' this.swallow}}\n {{on 'click' (fn this.toggleDiet guest diet.id)}}\n >{{diet.label}}</button>\n {{/each}}\n </div>\n\n <div class='tp-line'>\n <input\n class='tp-field'\n placeholder='anything else'\n value={{guest.note}}\n aria-label='Other notes for {{guest.name}}'\n {{on 'pointerdown' this.swallow}}\n {{on 'input' (fn this.setNote guest)}}\n />\n <span class='tp-phone'>{{guest.phone}}</span>\n </div>\n </div>\n {{/if}}\n </div>\n </article>\n </li>\n {{else}}\n <li class='tp-empty'>nobody left on the list</li>\n {{/each}}\n </ol>\n\n <div class='tp-tables'>\n {{#each this.tables key='id' as |table|}}\n <section\n class={{this.tableClass table.id}}\n {{this.bindTable table.id}}\n >\n <div class='tp-table-head'>\n <span>{{table.name}}</span>\n <small>{{this.freeAt table.id}}</small>\n </div>\n\n <ul class='tp-seats'>\n {{#each (this.seatedAt table.id) key='id' as |guest|}}\n {{! A chair is draggable too, and it is the whole pill rather\n than a tab on it — there is no form in here to protect, so\n there is nothing to take the pointer back from. The same\n element being a drag handle when it has no content of its\n own, and needing a separate one when it does, is the\n clearest way to say what `dragListener` is for. }}\n <li\n class={{this.seatClass guest.id}}\n {{motion\n drag=true\n dragSnapToOrigin=true\n dragTransition=(tuneObject 'grip' RETURN 'dragTransition')\n initial=SEAT_IN\n animate=SEAT_HERE\n transition=(tuneMotion 'grip' SEAT_SPRING 'SEAT_SPRING')\n onPanSessionStart=this.session\n onDragStart=(fn this.hoist guest.id)\n onDrag=this.track\n onDragEnd=(fn this.drop guest)\n }}\n >\n <span>{{guest.name}}</span>\n </li>\n {{/each}}\n </ul>\n\n {{#if (this.feudAt table.id)}}\n <p class='tp-wrong'>Nadia and Raj do not speak.</p>\n {{/if}}\n </section>\n {{/each}}\n </div>\n </div>\n </div>\n <style scoped>\n .chip {\n border: 1px solid var(--line);\n background: transparent;\n color: var(--ink-dim);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n .chip.is-on {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n\n @media (hover: hover) {\n .chip:hover {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n }\n\n .chip.is-on {\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.35);\n color: var(--ember-hot);\n }\n\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n @media (max-width: 720px) {\n .chip {\n padding: 9px 14px;\n }\n }\n\n /* ---- Table Plan: seat twelve, by the corner tab ---- */\n\n .tp-ex {\n display: flex;\n flex-direction: column;\n gap: 10px;\n width: min(760px, 100%);\n }\n\n .tp-head {\n align-items: baseline;\n display: flex;\n gap: 10px;\n }\n\n .tp-tally {\n color: var(--ink);\n font-family: var(--font-mono);\n font-size: 12px;\n letter-spacing: 0.04em;\n white-space: nowrap;\n }\n\n .tp-tally.is-done {\n color: var(--copper-ink);\n }\n\n .tp-note {\n color: var(--ink-faint);\n flex: 1;\n font-size: 11px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .tp-board {\n display: grid;\n flex: 1;\n gap: 12px;\n grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);\n min-height: 0;\n }\n\n .tp-rail.is-live {\n background: rgba(230, 163, 90, 0.05);\n box-shadow: inset 0 0 0 1px rgba(230, 163, 90, 0.3);\n }\n\n .tp-rail.is-over {\n background: rgba(230, 163, 90, 0.12);\n box-shadow: inset 0 0 0 1px var(--copper);\n }\n\n .tp-rail {\n border-radius: 8px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n list-style: none;\n margin: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 0;\n scrollbar-gutter: stable;\n scrollbar-width: thin;\n }\n\n .tp-empty {\n color: var(--ink-faint);\n font-size: 11px;\n padding: 6px 2px;\n }\n\n /* The card: a grip column on the left, everything that keeps its own pointer on\n the right, and a rule between them. `touch-action: none` so a phone does not\n read the lift as a scroll. */\n .tp-card {\n align-items: stretch;\n background: var(--bg-elev);\n border: 1px solid var(--line);\n border-radius: 8px;\n display: grid;\n grid-template-columns: 26px minmax(0, 1fr);\n overflow: hidden;\n position: relative;\n touch-action: none;\n }\n\n .tp-card.is-open {\n border-color: var(--line-strong);\n }\n\n /* Carried: over the tables, not under them. The rail and the tables are\n siblings in a grid, so a card with no z-index of its own slides beneath the\n thing it is being carried toward and vanishes at the moment the drop matters\n most. The shadow is the rest of the sentence — it says the card has left the\n list rather than merely moved along it.\n\n And it SHRINKS. A full-width card in flight covers the tables it is being\n aimed at, so the one thing you need to see while dragging is the thing the\n drag is hiding. Only the width is animated, deliberately: the row keeps its\n height, so nothing below it reflows and the list you are dragging out of\n stays still. */\n .tp-card.tp-lift {\n border-color: var(--line-strong);\n box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);\n cursor: grabbing;\n max-width: 190px;\n /* NOT transitioned. `max-width` is a layout property, so animating it makes\n the browser reflow the card — and the rail it sits in — on every frame of\n the transition, while the drag is already writing a transform to the same\n element each frame. The shrink is instant instead: at the moment you pick\n something up, an instant change reads as the thing becoming portable, and\n an animated one reads as lag. */\n z-index: 5;\n }\n\n /* the summary and the button go with it: a pill in flight is a name and a grip\n and nothing else */\n .tp-card.tp-lift .tp-diet,\n .tp-card.tp-lift .tp-edit,\n .tp-card.tp-lift .tp-flap {\n display: none;\n }\n\n /* and the rail itself has to stop clipping, or the card is cut off at its edge\n the moment it leaves */\n .tp-rail:has(.tp-lift) {\n overflow: visible;\n }\n\n /* The grip, and the rule down its right-hand side.\n This is the only press on the card that means \"carry me\" rather than \"use\n me\", and six dots pressed against a name do not say that. The line is doing\n real work: it marks where the draggable part stops and the form starts, which\n is precisely the boundary `dragListener=false` draws in the code. */\n .tp-grip {\n align-items: center;\n background: rgba(var(--ink-rgb), 0.04);\n border: 0;\n border-right: 1px solid var(--line);\n cursor: grab;\n display: grid;\n padding: 0;\n place-items: center;\n width: 26px;\n }\n\n .tp-grip:hover {\n background: rgba(var(--ink-rgb), 0.08);\n }\n\n .tp-grip:active {\n cursor: grabbing;\n }\n\n .tp-dots {\n display: grid;\n gap: 2px;\n grid-template-columns: repeat(2, 3px);\n }\n\n .tp-dots i {\n background: var(--ink-faint);\n border-radius: 50%;\n height: 3px;\n opacity: 0.75;\n width: 3px;\n }\n\n .tp-grip:hover .tp-dots i {\n opacity: 1;\n }\n\n .tp-main {\n display: flex;\n flex-direction: column;\n gap: 6px;\n min-width: 0;\n padding: 7px 8px;\n }\n\n .tp-who {\n align-items: baseline;\n display: flex;\n gap: 8px;\n min-width: 0;\n }\n\n .tp-who b {\n flex: none;\n font-size: 12.5px;\n }\n\n .tp-diet {\n color: var(--copper-ink);\n flex: 1;\n font-family: var(--font-mono);\n font-size: 10px;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n /* The visible way in. A tap on the card opens it too, but a tap is something\n you have to already know about. */\n .tp-edit {\n background: rgba(var(--ink-rgb), 0.07);\n border: 0;\n border-radius: 5px;\n color: var(--ink-faint);\n cursor: pointer;\n flex: none;\n font-family: var(--font-mono);\n font-size: 9.5px;\n letter-spacing: 0.08em;\n padding: 3px 7px;\n }\n\n .tp-edit:hover {\n color: var(--ink);\n }\n\n /* The half of the card that could not exist under `drag=true`: six checkboxes\n and a field, all of them wanting the pointer the drag would have taken. */\n .tp-flap {\n display: flex;\n flex-direction: column;\n gap: 6px;\n }\n\n .tp-pills {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n }\n\n /* Fixed size, at every stage width. These are checkboxes: a checkbox that grows\n and shrinks with the column it happens to be in reads as a different control\n each time, and the eye stops trusting the tick. */\n .tp-pill {\n background: rgba(var(--ink-rgb), 0.06);\n border: 1px solid transparent;\n border-radius: 999px;\n color: var(--ink-faint);\n cursor: pointer;\n font-family: var(--font-mono);\n font-size: 10px;\n line-height: 1.4;\n padding: 2px 8px;\n transition:\n background 140ms var(--ease),\n border-color 140ms var(--ease),\n color 140ms var(--ease);\n white-space: nowrap;\n }\n\n .tp-pill:hover {\n color: var(--ink);\n }\n\n .tp-pill.is-on {\n background: rgba(230, 163, 90, 0.16);\n border-color: rgba(230, 163, 90, 0.45);\n color: var(--copper-ink);\n }\n\n .tp-line {\n align-items: center;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n }\n\n .tp-field {\n background: rgba(var(--ink-rgb), 0.06);\n border: 1px solid var(--line);\n border-radius: 5px;\n color: var(--ink);\n flex: 1;\n font-size: 11px;\n min-width: 120px;\n padding: 3px 7px;\n }\n\n .tp-field:focus {\n border-color: var(--line-strong);\n }\n\n /* the site's focus ring, held close: the field sits in a dense card */\n .tp-field:focus-visible {\n outline-offset: 1px;\n }\n\n .tp-phone {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 10.5px;\n user-select: text;\n }\n\n .tp-tables {\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-height: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n scrollbar-gutter: stable;\n scrollbar-width: thin;\n }\n\n /* A table is a drop target and it has four things to say, in order of how\n urgently they are wanted: this is where it would land (`is-over`), this is\n somewhere it COULD land (`is-live`), this one would refuse you (`is-no`),\n and this one is already wrong (`is-wrong`).\n The hover state wins over the merely-available one because it answers the\n question actually being asked. Three identical lit boxes tell you nothing at\n the moment you are choosing between them. */\n .tp-table {\n background: rgba(var(--ink-rgb), 0.03);\n border: 1px dashed var(--line-strong);\n border-radius: 8px;\n padding: 7px 9px;\n transition:\n background 160ms var(--ease),\n border-color 160ms var(--ease);\n }\n\n .tp-table.is-live {\n background: rgba(230, 163, 90, 0.08);\n border-color: rgba(230, 163, 90, 0.4);\n }\n\n .tp-table.is-over {\n background: rgba(230, 163, 90, 0.2);\n border-color: var(--copper);\n border-style: solid;\n }\n\n /* Refusing in advance. A full table under the pointer says no BEFORE you let\n go, which is the difference between a rule and a punishment: the snap-back\n still happens, but nobody was surprised by it. */\n .tp-table.is-no {\n background: rgba(255, 59, 31, 0.1);\n border-color: rgba(255, 59, 31, 0.5);\n border-style: solid;\n opacity: 1;\n }\n\n .tp-table.is-full {\n opacity: 0.55;\n }\n\n .tp-table.is-wrong {\n background: rgba(255, 59, 31, 0.08);\n border-color: rgba(255, 59, 31, 0.45);\n border-style: solid;\n }\n\n .tp-table-head {\n color: var(--ink-dim);\n display: flex;\n font-family: var(--font-mono);\n font-size: 10px;\n justify-content: space-between;\n letter-spacing: 0.06em;\n margin-bottom: 5px;\n }\n\n .tp-table-head small {\n color: var(--ink-faint);\n }\n\n .tp-seats {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n list-style: none;\n margin: 0;\n min-height: 22px;\n padding: 0;\n }\n\n .tp-seat {\n align-items: center;\n background: var(--bg-elev);\n border: 1px solid var(--line);\n border-radius: 999px;\n cursor: grab;\n display: inline-flex;\n font-size: 11.5px;\n padding: 3px 10px;\n touch-action: none;\n }\n\n .tp-seat:active {\n cursor: grabbing;\n }\n\n .tp-seat.tp-lift {\n border-color: var(--line-strong);\n box-shadow: 0 10px 22px rgba(0, 0, 0, 0.3);\n position: relative;\n z-index: 5;\n }\n\n .tp-wrong {\n color: var(--ember);\n font-family: var(--font-mono);\n font-size: 10px;\n margin: 5px 0 0;\n }\n @container platter (max-width: 520px) {\n .tp-board {\n grid-template-columns: minmax(0, 1fr);\n }\n }\n </style>\n </template>\n}\n\nexport default Grip;\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneObject('grip', RETURN, 'dragTransition');\ntuneMotion('grip', SEAT_SPRING, 'SEAT_SPRING');\n\nexport class GripDemo extends GalleryDemo {\n static stage = Grip;\n}\n";
433
433
  //#endregion
434
434
  //#region ../choreo-gallery/realm/stages/hang.gts?raw
435
435
  var hang_default = "import type { DeriveContext } from '@cardstack/choreo';\nimport { beacon, Choreo } from '@cardstack/choreo';\nimport { array, concat } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport type { DialConfig } from 'dialkit/store';\nimport { modifier } from 'ember-modifier';\nimport { motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { Dial } from '../lib/dial';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneNumber, tuneSeconds, tuneSpring } from '../lib/tuning';\nimport { DialPanel } from '../shell/dial-panel';\n\n/**\n * Shuffleboard, which is a game about one number: how fast your hand was\n * moving at the instant you let go.\n *\n * Every other stage in this gallery can be played by pressing a button. This\n * one cannot, and that is the point. The puck cannot be carried to where you\n * want it — the shooting area is walled, so the distance has to come out of\n * the THROW, and a throw is a quantity no measurement in a layout pass\n * contains. Choreo can measure where the finger stopped; only `c.gesture`\n * knows how fast it was going when it stopped there.\n *\n * ## Why a flick game and not a physics toy\n *\n * The tempting version of this stage is the one where the puck is handed to\n * `dragMomentum` and slides on Motion's own inertia. It would look right and\n * prove nothing: inertia has no destination and no rules, so the library\n * doing the animating would be the one we did not write.\n *\n * `dragMomentum=false` here on purpose. The throw is READ (release point and\n * release velocity), a resting place is RESOLVED from it (a zone, a knock, or\n * off the end), and only then is the flight AUTHORED to that place with the\n * throw's own speed borrowed as its opening velocity. The flick decides where\n * the puck goes and how the going feels; the rules decide where it is allowed\n * to stop. That split is the thing worth demonstrating, and it is invisible\n * in any demo where the physics is also the answer.\n *\n * ## A wall, not a foul\n *\n * A foul line — let go past it and the throw is scratched — would teach the\n * same lesson and be miserable to play: a rule that punishes you for the\n * thing the interface let you do. `dragConstraints` says it better. The\n * shooting area is the constraint, so the drag simply stops at the wall and\n * nobody is ever told off. Same lesson, no rule.\n *\n * ## The identity that keeps it honest\n *\n * `COAST` and `SLIDE` are the same number twice. If the projection used to\n * pick the destination disagreed with the spring that flies the puck there,\n * the puck would visibly lie about how hard you threw it — coasting past and\n * being hauled back, or arriving early and creeping the last inch. Any stage\n * that predicts a landing and then animates to it owes itself this check.\n */\n\n/**\n * The two numbers this stage rests on, tuned live from the panel beside it.\n *\n * The whole stage's honesty depends on them and on COAST being derived from\n * them, so they are the ones worth an instrument.\n *\n * `[value, min, max, step]` is dialkit's slider tuple. The panel is drawn by\n * `DialPanel`; the store→tracked bridge is `lib/dial.ts`.\n */\nconst TUNING = {\n damping: [28, 4, 60, 1],\n stiffness: [100, 20, 400, 5],\n} satisfies DialConfig;\n\n/** the long glide: overdamped, so it reads as friction rather than a bounce */\nconst SLIDE = { damping: 28, stiffness: 100 };\n/** the shove a puck passes to the puck it hits — short, and all at once */\nconst KNOCK = { damping: 19, stiffness: 340 } as const;\n/** a throw too soft to leave the shooting area, handed back */\nconst HOME = { damping: 24, stiffness: 300 } as const;\n/** off the end, into the gutter */\nconst OFF = { damping: 28, stiffness: 200 } as const;\n\n/**\n * Seconds of coast — and NOT a free parameter.\n *\n * An overdamped spring decays with a time constant of damping/stiffness, and\n * a puck released at velocity v under exponential decay travels v·τ before it\n * stops. So the distance a throw is projected over must be the spring's own\n * τ, or the projection and the flight are two different physics arguing over\n * one puck. Retune SLIDE and this follows it.\n */\nexport const coastOf = (spring: { damping: number; stiffness: number }) =>\n spring.damping / spring.stiffness;\n\nexport const COAST = coastOf(SLIDE);\n\n/** the shooting area, as a fraction of the lane. A wall, not a rule. */\nexport const RUNWAY = 0.28;\n\n/**\n * The scoring bands, near to far, as fractions of the lane — and they are\n * spaced by what a hand can actually do. From the wall, with τ = 0.28s, the\n * ladder is roughly: 400 px/s reaches the 1, 900 the 2, 1250 the 3, 1600 the\n * hang, and it takes better than 1900 to throw it away entirely. Every band\n * is inside an ordinary flick, and the failure at the top takes real effort.\n *\n * The hang is deliberately the WIDEST band, not the narrowest. It is the\n * boundary a player is aiming at and the one the preview is least able to\n * promise — `onDrag` is dispatched a frame behind the pointer, so a hand\n * still accelerating is previewed slightly slow. A hair-thin hang would flip\n * to the gutter between the last frame and the release, which reads as the\n * game lying rather than as a hand that was a little heavy.\n */\nexport const ZONES = [\n { from: 0.44, name: '1', score: 1 },\n { from: 0.62, name: '2', score: 2 },\n { from: 0.76, name: '3', score: 3 },\n { from: 0.9, name: 'hang', score: 4 },\n] as const;\n\nconst BANDS = ZONES.map((z, i) => ({\n name: z.name,\n style: htmlSafe(\n `left:${z.from * 100}%;width:${((ZONES[i + 1]?.from ?? 1) - z.from) * 100}%`,\n ),\n}));\n\n/** the puck, in px — the diameter every collision is measured against */\nexport const PUCK = 44;\nexport const THROWS = 8;\n\nexport type Side = 'blue' | 'red';\n\nexport interface Puck {\n id: number;\n side: Side;\n /** the CENTRE, as a fraction of the lane's width */\n x: number;\n}\n\nexport function scoreOf(x: number) {\n let n = 0;\n for (const z of ZONES) {\n if (x >= z.from) {\n n = z.score;\n }\n }\n return n;\n}\n\n/** what the aim preview calls the place the puck is currently headed */\nexport function callIt(x: number) {\n if (x > 1) {\n return 'off the end';\n }\n if (x <= RUNWAY) {\n return 'too soft';\n }\n const z = scoreOf(x);\n return z === 0 ? 'short' : z === 4 ? 'the hang' : String(z);\n}\n\n/**\n * The entire collision model: put the arrival on the board and sweep once,\n * near end to far.\n *\n * The board was already spaced before this throw, so the only pair that can\n * overlap is the one the arrival made — and pushing that puck clear may in\n * turn overlap the next, which the same sweep keeps resolving. A chain\n * reaction, in a loop with no special case in it. Anything shoved past the\n * far end has left the board.\n */\nexport function settle(board: Puck[], arrival: Puck, gap: number) {\n const next = [...board, arrival].sort((a, b) => a.x - b.x);\n for (let i = 1; i < next.length; i++) {\n if (next[i]!.x - next[i - 1]!.x < gap) {\n next[i] = { ...next[i]!, x: next[i - 1]!.x + gap };\n }\n }\n return {\n off: next.filter((p) => p.x > 1),\n pucks: next.filter((p) => p.x <= 1),\n };\n}\n\n/**\n * Shuffleboard's real rule, and the reason a board is worth building: only\n * ONE colour scores, and it scores every puck it has ahead of the other\n * colour's best. So the board is never a running sum of throws — every throw\n * re-reads the whole thing, and one knock can hand the round over.\n */\nexport function standing(board: Puck[]) {\n const best = (side: Side) =>\n board.reduce((n, p) => (p.side === side && p.x > n ? p.x : n), -1);\n const red = best('red');\n const blue = best('blue');\n if (red === blue) {\n return { lead: null, score: 0 };\n }\n const lead: Side = red > blue ? 'red' : 'blue';\n const mark = lead === 'red' ? blue : red;\n return {\n lead,\n score: board.reduce(\n (n, p) => (p.side === lead && p.x > mark ? n + scoreOf(p.x) : n),\n 0,\n ),\n };\n}\n\n/**\n * The lead line: a hairline standing at the furthest puck on the board.\n *\n * This is the case `c.Follow` exists for, and it is worth being exact about\n * why a keyframe could not do it. The line's destination is not known when\n * the score is written: the furthest puck at the end of this pass might be\n * the one being thrown, or a puck it knocked forward, or the puck that was\n * already leading and never moved — and which of those wins can change PART\n * WAY THROUGH the flight, at a moment nobody can name in advance. `@read`\n * runs every frame over every puck the pass is holding and takes the max, so\n * the line changes allegiance exactly when the lead changes hands.\n *\n * It is handed measured boxes, never the live page, so it cannot read back\n * what it wrote last frame. And `@rest` is `x:0` because the stylesheet has\n * already parked the line at the new leader (`--lead`): at the end of the run\n * the max IS that resting position, the derived value goes to zero on its\n * own, and the handover to rest is seamless rather than a snap.\n */\nconst lead = ({ rest, sources }: DeriveContext) => {\n let far = -Infinity;\n for (const s of sources) {\n const mid = s.now.x + s.now.width / 2;\n if (mid > far) {\n far = mid;\n }\n }\n return { x: far === -Infinity ? 0 : far - (rest.x + rest.width / 2) };\n};\nconst LEAD_REST = { x: 0 } as const;\n\nconst OPENING = 'flick the puck — the ghost shows where it would land';\n/** the fastest flick the power bar bothers to draw */\nconst FULL = 2000;\n\nexport class Hang extends Component {\n @tracked pucks: Puck[] = [];\n @tracked thrown = 0;\n /** how many left the board — the gutter is a tally, not a list */\n @tracked lost = 0;\n /** bumped on a dud, to re-key the cradle puck (see `cradle`) */\n @tracked nudge = 0;\n @tracked speed = 0;\n @tracked note = OPENING;\n\n bands = BANDS;\n throws = THROWS;\n runwayStyle = htmlSafe(`width:${RUNWAY * 100}%`);\n\n /**\n * The slide spring, live off the dial rather than off a constant.\n *\n * `resolveDialValues` returns `{ damping, stiffness }` — which is already\n * exactly the spring spec `@spring` wants, so the dial's output is handed\n * straight to the score with nothing in between. Move a slider and the\n * region's `treePrint` changes, so the pass replays: you watch the spring\n * you are editing, while you edit it.\n */\n dial = new Dial('hang-slide', 'The slide', TUNING);\n\n get slide() {\n return this.dial.values as unknown as {\n damping: number;\n stiffness: number;\n };\n }\n\n /** still derived, still the spring's own time constant — see coastOf */\n get coast() {\n return coastOf(this.slide);\n }\n\n willDestroy() {\n super.willDestroy();\n this.dial.teardown();\n }\n\n private laneEl: HTMLElement | null = null;\n private ghostEl: HTMLElement | null = null;\n private powerEl: HTMLElement | null = null;\n /** handed to dragConstraints — Motion measures it and drags inside it */\n pen: { current: HTMLElement | null } = { current: null };\n\n bindLane = modifier((el: HTMLElement) => {\n this.laneEl = el;\n const surface = el as HTMLElement & { playDemoShot?: () => void };\n surface.playDemoShot = this.demoShot;\n return () => {\n delete surface.playDemoShot;\n this.laneEl = null;\n };\n });\n\n bindPen = modifier((el: HTMLElement) => {\n this.pen.current = el;\n return () => {\n this.pen.current = null;\n };\n });\n\n bindGhost = modifier((el: HTMLElement) => {\n this.ghostEl = el;\n return () => {\n this.ghostEl = null;\n };\n });\n\n bindPower = modifier((el: HTMLElement) => {\n this.powerEl = el;\n return () => {\n this.powerEl = null;\n };\n });\n\n get side(): Side {\n return this.thrown % 2 === 0 ? 'red' : 'blue';\n }\n\n get done() {\n return this.thrown >= THROWS;\n }\n\n /**\n * A dud leaves the board untouched, so there is no layout change for a pass\n * to notice — and the puck would sit there wearing the transform the drag\n * left on it. Re-keying replaces the element instead: the same id removed\n * and inserted in one pass, which is a counterpart pair whose arrival is a\n * fresh, untransformed puck in its proper seat. The flight home is then the\n * ordinary `c.received` flight, borrowing the same gesture a real throw\n * borrows. One mechanism, both outcomes.\n */\n get cradle() {\n return [`${this.thrown}:${this.nudge}`];\n }\n\n get seats() {\n return this.pucks.map((p) => ({\n id: p.id,\n mark: scoreOf(p.x),\n side: p.side,\n style: htmlSafe(`left:${p.x * 100}%`),\n }));\n }\n\n get standing() {\n return standing(this.pucks);\n }\n\n /** where the stylesheet parks the lead line: the furthest puck at rest */\n get leadStyle() {\n return htmlSafe(\n `--lead:${this.pucks.reduce((n, p) => (p.x > n ? p.x : n), 0)}`,\n );\n }\n\n /** the read that both the preview and the throw are decided by */\n private project(clientX: number, vx: number, rect: DOMRect) {\n const at = Math.min(RUNWAY, (clientX - rect.left) / rect.width);\n return at + (vx * this.coast) / rect.width;\n }\n\n /**\n * The aim preview, and the one piece of this stage that is deliberately\n * NOT in the score.\n *\n * It runs on every pointer move while nothing is animating, so putting it\n * through Glimmer would re-render the region — and re-measure every sprite\n * in it — sixty times a second to move one dashed ring. There is no state\n * here worth keeping: the ghost is a readout of the pointer, it exists only\n * while a finger is down, and the moment the finger lifts the SCORE takes\n * over and the imperative half is done. Written straight to the element.\n *\n * It previews `info.velocity.x` — the same windowed velocity Motion will\n * hand `onDragEnd`, projected through the same `project` — so the ghost is\n * not an approximation of the throw, it is the throw, asked early.\n */\n aim = (event: PointerEvent, info: { velocity: { x: number; y: number } }) => {\n const lane = this.laneEl;\n const ghost = this.ghostEl;\n if (!lane || !ghost) {\n return;\n }\n const rect = lane.getBoundingClientRect();\n const x = this.project(event.clientX, info.velocity.x, rect);\n /**\n * `--at` is clamped so the ring stays on the lane where it can be seen;\n * `data-at` keeps the raw projection, because a throw headed well past\n * the end is a different thing from one that just reaches it and the\n * readout should not quietly round the two together.\n */\n ghost.style.setProperty('--at', String(Math.max(0, Math.min(1, x))));\n ghost.dataset['at'] = x.toFixed(3);\n ghost.dataset['call'] = callIt(x);\n ghost.textContent = callIt(x);\n if (this.powerEl) {\n const p = Math.max(0, Math.min(1, Math.abs(info.velocity.x) / FULL));\n this.powerEl.style.setProperty('--power', String(p));\n }\n };\n\n private clearAim() {\n this.ghostEl?.style.removeProperty('--at');\n this.ghostEl?.removeAttribute('data-at');\n this.ghostEl?.removeAttribute('data-call');\n this.powerEl?.style.removeProperty('--power');\n }\n\n /** Guided-tour/capture handle: use the same landing model as a real release. */\n demoShot = () => {\n if (!this.laneEl) {\n return;\n }\n const rect = this.laneEl.getBoundingClientRect();\n const start = RUNWAY * 0.7;\n const destination = this.thrown % 2 ? 0.86 : 0.76;\n this.land(\n new PointerEvent('pointerup', {\n clientX: rect.left + start * rect.width,\n }),\n {\n velocity: {\n x: ((destination - start) * rect.width) / this.coast,\n y: 0,\n },\n },\n );\n };\n\n rack = () => {\n this.pucks = [];\n this.thrown = 0;\n this.lost = 0;\n this.speed = 0;\n this.note = OPENING;\n };\n\n /**\n * Read the throw, resolve where it is allowed to stop, change the state.\n *\n * Nothing in here animates anything, and that is worth noticing: the whole\n * flight — the slide, the knock, the dimming, the gutter, the lead line —\n * is the score below, reacting to a board that simply became different.\n */\n land = (\n event: PointerEvent,\n info: { velocity: { x: number; y: number } },\n ) => {\n const lane = this.laneEl;\n this.clearAim();\n if (!lane || this.done) {\n return;\n }\n const rect = lane.getBoundingClientRect();\n this.speed = Math.round(info.velocity.x);\n const x = this.project(event.clientX, info.velocity.x, rect);\n\n /**\n * Too soft to leave the shooting area. Not a penalty and not a scratch —\n * the puck comes back and the throw is still to come, because a stage\n * that costs you a turn for under-flicking teaches flinching, not aim.\n */\n if (x <= RUNWAY) {\n this.nudge += 1;\n this.note = 'too soft — it never left the pen. Try again, harder.';\n return;\n }\n\n const mine: Puck = { id: this.thrown, side: this.side, x };\n const { off, pucks } = settle(this.pucks, mine, PUCK / rect.width);\n const rested = pucks.find((p) => p.id === mine.id);\n\n this.pucks = pucks;\n this.lost += off.length;\n this.thrown += 1;\n this.note = !rested\n ? 'off the end — that was too much'\n : rested.x < ZONES[0]!.from\n ? 'short of the scoring bands, but it is in the way now'\n : off.length > 0\n ? `${scoreOf(rested.x)} — and one knocked clean off`\n : `${scoreOf(rested.x)}`;\n };\n\n <template>\n <div class='ex hang-ex no-select' {{on 'selectstart' preventSelect}}>\n <header class='hang-head'>\n <span class='hang-turn is-{{this.side}}'>\n {{if this.done 'rack empty' (concat this.side ' throws')}}\n </span>\n <span class='hang-tally'>{{this.thrown}}/{{this.throws}}</span>\n <span class='hang-standing'>\n {{#if this.standing.lead}}\n <b class='is-{{this.standing.lead}}'>{{this.standing.lead}}</b>\n {{this.standing.score}}\n {{else}}\n <i>tied</i>\n {{/if}}\n </span>\n {{! the wind-up, drawn while the finger is down }}\n <span class='hang-power' {{this.bindPower}}><i></i></span>\n <span class='hang-speed'>\n {{#if this.speed}}{{this.speed}} px/s{{/if}}\n </span>\n <button type='button' class='chip' {{on 'click' this.rack}}>\n rack again\n </button>\n </header>\n\n <Choreo class='hang-table' as |c|>\n <div\n class='hang-lane {{unless this.pucks.length \"is-empty\"}}'\n style={{this.leadStyle}}\n {{this.bindLane}}\n >\n {{#each this.bands key='name' as |band|}}\n <span class='hang-zone' style={{band.style}}><i\n >{{band.name}}</i></span>\n {{/each}}\n\n {{! the shooting area. Handed to dragConstraints, so it is a wall\n the puck stops at rather than a line you can be punished for\n crossing — the distance still has to come out of the throw. }}\n <div class='hang-pen' style={{this.runwayStyle}} {{this.bindPen}}>\n {{#unless this.done}}\n {{#each this.cradle key='@identity'}}\n <span\n class='hang-puck hang-shot is-{{this.side}}'\n data-test-shot\n {{motion\n id=(concat 'puck-' this.thrown)\n role='cradle'\n drag='x'\n dragConstraints=this.pen\n dragMomentum=false\n dragElastic=(tuneNumber 'hang' 0.06 'dragElastic')\n onDrag=this.aim\n onDragEnd=this.land\n }}\n ></span>\n {{/each}}\n {{/unless}}\n </div>\n\n {{#each this.seats key='id' as |seat|}}\n <span\n class='hang-puck is-{{seat.side}}'\n style={{seat.style}}\n data-test-puck={{seat.id}}\n {{motion id=(concat 'puck-' seat.id) role='puck'}}\n >{{seat.mark}}</span>\n {{/each}}\n\n {{! where it would land if you let go now — see `aim` }}\n <span class='hang-ghost' {{this.bindGhost}}></span>\n\n <span class='hang-lead' {{motion id='lead'}}></span>\n </div>\n\n <footer class='hang-foot'>\n <span class='hang-note'>{{this.note}}</span>\n {{! a place, not a list: pucks that leave fly HERE and are counted.\n The gutter has no element per puck to become, which is exactly\n when a beacon is the right answer and a shared id is not. }}\n <span class='hang-gutter' {{beacon 'gutter'}}>\n gutter\n <b>{{this.lost}}</b>\n </span>\n </footer>\n\n <p class='hang-legend'>\n soft lands in the\n <b>1</b>\n · firm reaches the\n <b>3</b>\n · the\n <b>hang</b>\n is the far edge, and a hair more is the gutter\n </p>\n\n <c.Parallel>\n {{! The throw. The arrival is `received` because the cradle puck\n left and the lane puck claimed its id — two elements, one\n identity. @from replaces the measured start with the pointer,\n and the spring opens at the pointer's velocity, which is the\n one part of a throw no layout pass can supply. }}\n <c.Move\n @of={{c.received 'puck'}}\n @from={{c.gesture}}\n @spring={{tuneSpring 'hang' this.slide 'slide'}}\n @size={{false}}\n @swap='none'\n />\n\n {{! a dud: same pair, same borrowed gesture, handed back instead }}\n <c.Move\n @of={{c.received 'cradle'}}\n @from={{c.gesture}}\n @spring={{tuneSpring 'hang' HOME 'HOME'}}\n @size={{false}}\n @swap='none'\n />\n\n {{! the half either arrival claimed, parked in the orphan layer }}\n <c.Tween\n @of={{c.counterpart}}\n @opacity={{0}}\n @duration={{tuneSeconds 'hang' 0.1 'Step 1 duration'}}\n />\n\n {{! The knock. Nothing told these to move: the collision sweep\n changed their seats and the changeset noticed. A different\n spring because it is a different event — a transferred shove\n is not a throw. }}\n <c.Move\n @of={{c.moved 'puck'}}\n @spring={{tuneSpring 'hang' KNOCK 'KNOCK'}}\n @size={{false}}\n />\n\n {{! and what the throw did NOT disturb dims for the length of it,\n so a four-puck chain reaction reads as one thing. Freeze this\n and you cannot tell what the throw touched. }}\n <c.Hold\n @of={{c.still 'puck'}}\n @opacity={{0.38}}\n @duration={{tuneSeconds 'hang' 0.55 'Hold duration 1'}}\n />\n\n {{! shoved off the far end: removed, claimed by nobody, so it has\n nowhere to land. The beacon is the somewhere. }}\n <c.Move\n @of={{c.removed 'puck'}}\n @to={{c.beacon 'gutter'}}\n @spring={{tuneSpring 'hang' OFF 'OFF'}}\n />\n <c.Tween\n @of={{c.removed 'puck'}}\n @opacity={{0}}\n @duration={{tuneSeconds 'hang' 0.42 'Step 2 duration'}}\n />\n\n {{! the lead line, derived per frame from every puck on the board }}\n <c.Follow\n @of={{c.id 'lead'}}\n @to={{c.kept 'puck'}}\n @read={{lead}}\n @rest={{LEAD_REST}}\n @duration={{tuneSeconds 'hang' 1.6 'Follow duration 2'}}\n />\n\n {{! the next puck into the pen, once the last one is away }}\n <c.Tween\n @of={{c.inserted 'cradle'}}\n @opacity={{array 0 1}}\n @scale={{array 0.5 1}}\n @duration={{tuneSeconds 'hang' 0.24 'Step 3 duration'}}\n @delay={{0.12}}\n />\n </c.Parallel>\n </Choreo>\n\n <DialPanel @dial={{this.dial}} />\n </div>\n <style scoped>\n .chip {\n border: 1px solid var(--line);\n background: transparent;\n color: var(--ink-dim);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n @media (hover: hover) {\n .chip:hover {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n }\n\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n @media (max-width: 720px) {\n .chip {\n padding: 9px 14px;\n }\n }\n\n .hang-ex {\n align-items: stretch;\n display: flex;\n flex-direction: column;\n gap: 14px;\n width: min(760px, 100%);\n }\n @container (max-height: 430px) {\n .hang-ex {\n transform: scale(0.88);\n transform-origin: center;\n }\n }\n @container (max-width: 400px) {\n .hang-ex {\n transform: scale(0.72);\n }\n }\n\n .hang-head {\n align-items: center;\n display: flex;\n flex-wrap: wrap;\n font-family: var(--font-mono);\n font-size: 11px;\n gap: 10px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n .hang-turn {\n border-radius: 999px;\n color: #fff;\n padding: 5px 11px;\n }\n\n .hang-turn.is-red {\n background: var(--ember);\n }\n\n .hang-turn.is-blue {\n background: var(--azure);\n }\n\n .hang-tally,\n .hang-speed {\n color: var(--ink-faint);\n }\n\n .hang-speed {\n font-variant-numeric: tabular-nums;\n margin-left: auto;\n }\n\n .hang-standing {\n color: var(--ink-dim);\n }\n\n .hang-standing b.is-red {\n color: var(--ember);\n }\n\n .hang-standing b.is-blue {\n color: var(--azure);\n }\n\n .hang-standing i {\n color: var(--ink-faint);\n font-style: normal;\n }\n\n .hang-table {\n display: flex;\n flex-direction: column;\n gap: 12px;\n }\n .hang-lane {\n background: var(--bg-well);\n border: 2px solid var(--line-strong);\n border-radius: 14px;\n height: 132px;\n overflow: hidden;\n position: relative;\n touch-action: none;\n }\n\n .hang-zone {\n border-left: 1px solid var(--line);\n bottom: 0;\n position: absolute;\n top: 0;\n }\n\n .hang-zone:nth-child(1) {\n background: rgba(var(--shadow-rgb), 0.04);\n }\n\n .hang-zone:nth-child(2) {\n background: rgba(var(--shadow-rgb), 0.07);\n }\n\n .hang-zone:nth-child(3) {\n background: rgba(var(--shadow-rgb), 0.1);\n }\n\n .hang-zone:nth-child(4) {\n background: rgba(var(--shadow-rgb), 0.14);\n }\n\n .hang-zone i {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 9px;\n font-style: normal;\n left: 5px;\n letter-spacing: 0.1em;\n position: absolute;\n text-transform: uppercase;\n top: 6px;\n }\n .hang-pen {\n background: rgba(var(--shadow-rgb), 0.05);\n border-right: 2px solid var(--line-strong);\n bottom: 0;\n left: 0;\n position: absolute;\n top: 0;\n }\n\n .hang-puck {\n align-items: center;\n border-radius: 50%;\n box-shadow: 0 4px 12px -4px rgba(var(--shadow-rgb), var(--shadow-a));\n color: #fff;\n display: flex;\n font-family: var(--font-mono);\n font-size: 12px;\n height: 44px;\n justify-content: center;\n position: absolute;\n top: calc(50% - 22px);\n touch-action: none;\n width: 44px;\n }\n .hang-lane > .hang-puck {\n margin-left: -22px;\n }\n\n .hang-puck.is-red {\n background: var(--ember);\n }\n\n .hang-puck.is-blue {\n background: var(--azure);\n }\n\n .hang-shot {\n cursor: grab;\n left: 8px;\n }\n\n .hang-shot:active {\n cursor: grabbing;\n }\n .hang-lead {\n background: var(--ink-faint);\n bottom: 0;\n left: calc(var(--lead, 0) * 100%);\n position: absolute;\n top: 0;\n width: 1px;\n }\n\n .hang-lane.is-empty .hang-lead {\n opacity: 0;\n }\n .hang-ghost {\n align-items: center;\n border: 2px dashed var(--ink-faint);\n border-radius: 50%;\n color: var(--ink-dim);\n display: flex;\n font-family: var(--font-mono);\n font-size: 10px;\n height: 44px;\n justify-content: center;\n left: calc(var(--at, 0) * 100%);\n letter-spacing: 0.04em;\n margin-left: -22px;\n opacity: 0;\n pointer-events: none;\n position: absolute;\n top: calc(50% - 22px);\n width: 44px;\n }\n\n .hang-ghost[data-call] {\n opacity: 1;\n }\n\n .hang-ghost[data-call='off the end'],\n .hang-ghost[data-call='too soft'] {\n border-color: var(--ember-hot);\n color: var(--ember-hot);\n font-size: 8px;\n }\n\n .hang-ghost[data-call='the hang'] {\n border-color: var(--teal);\n color: var(--teal);\n }\n .hang-power {\n background: var(--bg-elev);\n border-radius: 999px;\n height: 5px;\n overflow: hidden;\n width: 72px;\n }\n\n .hang-power i {\n background: var(--teal);\n display: block;\n height: 100%;\n transform: scaleX(var(--power, 0));\n transform-origin: left;\n width: 100%;\n }\n\n .hang-legend {\n color: var(--ink-faint);\n font-size: 11px;\n line-height: 1.6;\n margin: 0;\n }\n\n .hang-legend b {\n color: var(--ink-dim);\n font-family: var(--font-mono);\n font-weight: 400;\n }\n\n .hang-foot {\n align-items: center;\n display: flex;\n font-family: var(--font-mono);\n font-size: 11px;\n gap: 12px;\n justify-content: space-between;\n letter-spacing: 0.06em;\n }\n\n .hang-note {\n color: var(--ink-dim);\n }\n\n .hang-gutter {\n border: 1px dashed var(--line-strong);\n border-radius: 10px;\n color: var(--ink-faint);\n padding: 7px 12px;\n text-transform: uppercase;\n }\n\n .hang-gutter b {\n color: var(--ink);\n font-variant-numeric: tabular-nums;\n margin-left: 6px;\n }\n\n @media (max-width: 640px) {\n .hang-lane {\n height: 108px;\n }\n\n .hang-speed {\n margin-left: 0;\n }\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneNumber('hang', 0.06, 'dragElastic');\ntuneSpring('hang', HOME, 'HOME');\ntuneSeconds('hang', 0.1, 'Step 1 duration');\ntuneSpring('hang', KNOCK, 'KNOCK');\ntuneSeconds('hang', 0.55, 'Hold duration 1');\ntuneSpring('hang', OFF, 'OFF');\ntuneSeconds('hang', 0.42, 'Step 2 duration');\ntuneSeconds('hang', 1.6, 'Follow duration 2');\ntuneSeconds('hang', 0.24, 'Step 3 duration');\n\nexport class HangDemo extends GalleryDemo {\n static stage = Hang;\n}\n";
@@ -453,7 +453,7 @@ var interrupt_default = "import { Choreo } from '@cardstack/choreo';\nimport { f
453
453
  var jump_default = "import { Choreo } from '@cardstack/choreo';\nimport { array, concat, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneSeconds } from '../lib/tuning';\n\n/**\n * Nine tests are red in a run of sixty. Find each one, mark it, and do not lose\n * your place.\n *\n * This stage was a list of twenty-four numbered takes and four buttons reading\n * \"take 3\", \"take 11\", \"take 19\", \"take 24\". It demonstrated the same three\n * steps it demonstrates now and it taught nothing, for a reason worth stating\n * plainly: the motion answered a question the interface never let anyone have.\n * You asked for take 11 by name, from a button an inch away, and the scroll\n * then told you where take 11 was — which you did not need, because you had\n * just named it. A jump is only information when you did not already know\n * where you were going.\n *\n * A failing test is the case where you genuinely do not. You know there are\n * nine; you do not know where they are, how far apart they are, or whether\n * they cluster. So each of the three steps has a job that survives being\n * frozen:\n *\n * - **`c.Scroll`** tells you how far down the run the failure is and whether it\n * sits with the others. Teleport instead and you get the row with none of the\n * geography. It also yields to the wheel, which here is a requirement rather\n * than a nicety: you are already scrolling to read an assertion when the next\n * jump lands, and a jump that fights your hand is worse than no jump.\n * - **`c.Raise`** is the only way the row can sit outside the pane's `overflow`\n * clip. `z-index` cannot buy that — a stacking context does not escape an\n * ancestor's clip. Frozen mid-flight without it, the row is sliced by the\n * pane's edge.\n *\n * The toolbar used to overlap the list so that the raise had a header to pass\n * over as well, which was a better demonstration and a worse interface: with\n * `land it → at the top`, a row scrolls under the bar, and raising it — the\n * step doing exactly its job — then covered the count. The bar has its own\n * row now. The clip is the half of the claim that matters, and it is the half\n * nothing else can do.\n * - **`c.Hold`** marks it, and `@fill` is the difference between a flash and a\n * record. Keeping the marks is how you find your place after scrolling away\n * to read a stack trace; flashing is right when you are only stepping\n * through. Both are real preferences a person has while triaging, which is\n * why the control says what they mean rather than saying `@fill`.\n *\n * `@debug` is still literal and still visible, because it is honestly a\n * developer control — but it is out of the app's own row, so it is not\n * competing with the verbs.\n */\n\nconst EASE = [0.32, 0.72, 0, 1] as const;\n\ninterface Spec {\n /** the assertion, for the ones that failed */\n because?: string;\n id: string;\n ms: number;\n name: string;\n ok: boolean;\n suite: string;\n}\n\n/**\n * A run of sixty, nine of them red, and the red ones deliberately NOT evenly\n * spaced: two of them adjacent near the top, a long clean stretch, then a\n * cluster at the bottom. That shape is the thing the scroll is reporting — a\n * run where the failures cluster is a different problem than one where they are\n * scattered, and you can see which from the travel alone.\n */\nconst SUITES: Array<[string, string[]]> = [\n [\n 'layout',\n [\n 'measures a resting box before the change',\n 'reads a transformed parent through its screen CTM',\n 'declines a pass nothing moved in',\n 'keeps a scroll offset out of the delta',\n 'reparents without a flash',\n 'survives a scaled ancestor',\n 'batches reads before writes',\n 'settles a nested group inside out',\n ],\n ],\n [\n 'presence',\n [\n 'holds a leaver until its exit finishes',\n 'claims an id a leaver gave up',\n 'runs popLayout out of the flow',\n 'reports exit complete once',\n 'cancels an exit that came back',\n 'keys by identity, not by index',\n ],\n ],\n [\n 'drag',\n [\n 'releases the global lock on unmount',\n 'projects momentum from release velocity',\n 'clamps to constraints with elastic',\n 'forwards a handle to controls.start',\n 'distinguishes a tap from a drag',\n 'maps a point through a viewBox',\n 'keeps the pointer captured across the fold',\n ],\n ],\n [\n 'choreo/compile',\n [\n 'orders a sequence by its anchors',\n 'stretches a step to its stagger ladder',\n 'gives a spring exactly two keyframes',\n 'names a step so a twin does not dedupe',\n 'fails a forward reference, named',\n 'multiplies a repeat into the schedule',\n 'resolves after(name) to end plus delay',\n 'compiles an empty region to nothing',\n ],\n ],\n [\n 'choreo/run',\n [\n 'seeks to a time it never played',\n 'reconstructs a relative cue under seek',\n 'holds a fill past its window',\n 'raises above an ancestor clip',\n 'yields a scroll to the wheel',\n 'pauses without losing the clock',\n 'plays a paused run from where it stood',\n 'ends once, not once per cue',\n ],\n ],\n [\n 'gestures',\n [\n 'starts a pan past the threshold',\n 'ends a press that left the element',\n 'reports velocity at release',\n 'suppresses hover while dragging',\n ],\n ],\n [\n 'camera',\n [\n 'fits a target to a padding share',\n 'aims without changing zoom',\n 'pans relative to the shot in force',\n 'clamps a shot to the world',\n 'composes a zoom onto an aim',\n ],\n ],\n [\n 'scroll',\n [\n 'observes against a container root',\n 'fires once when once is asked for',\n 'tracks progress through a band',\n 'stops at the end of the track',\n ],\n ],\n [\n 'value',\n [\n 'interpolates a colour through oklab',\n 'rounds a unitless value cleanly',\n 'reads back a transform it wrote',\n 'shares one value between two nodes',\n ],\n ],\n [\n 'ssr',\n [\n 'renders initial without a document',\n 'defers measurement to the first frame',\n 'emits no transform before hydration',\n 'matches the server pose on the first frame',\n ],\n ],\n [\n 'reorder',\n [\n 'moves a keyed node rather than rebuilding it',\n 'settles a dropped item against its neighbours',\n ],\n ],\n];\n\n/**\n * Which ones are red, by flat index into SUITES, each with the assertion that\n * belongs to THAT test.\n *\n * Worth stating because the first version got it wrong and it was the single\n * most damaging thing on the stage: the indices were picked for their spacing\n * and the assertions were written separately, so \"seek(2.4) landed at 1.9\"\n * ended up under a test about suppressing hover. Every row read as nonsense to\n * anyone who actually read it, which on a stage whose whole subject is READING\n * a list is fatal. The failures still cluster the way the scroll wants — two\n * adjacent near the top, a long clean stretch, three in a row in choreo/run —\n * but the shape is now chosen from rows that could plausibly fail together.\n */\nconst RED = new Map<number, string>([\n [3, 'expected 0 in the delta, got 118 — the container had scrolled'],\n [4, 'element was rebuilt: 2 mounts, expected 1'],\n [11, 'exit complete fired twice for \"card-3\"'],\n [19, 'expected (240, 96), got (480, 192) — viewBox scale not divided out'],\n [30, 'seek(2.4) landed at 1.9 — the relative cue lost its base'],\n [31, 'fill released at window end, expected held'],\n [32, 'row clipped by .pane overflow at y=312'],\n [42, 'zoom moved 1.00 to 1.18 during an aim'],\n [52, 'read back \"none\" after writing translateX(40px)'],\n]);\n\nconst SPECS: Spec[] = (() => {\n const out: Spec[] = [];\n let i = 0;\n for (const [suite, names] of SUITES) {\n for (const name of names) {\n const because = RED.get(i);\n out.push({\n because,\n id: `spec-${i}`,\n // a stable pseudo-duration: this list must not change between renders\n ms: 4 + ((i * 37) % 90),\n name,\n ok: because === undefined,\n suite,\n });\n i += 1;\n }\n }\n return out;\n})();\n\nconst ALIGNS = [\n { label: 'centred', value: 'center' },\n { label: 'at the top', value: 'start' },\n] as const;\n\nconst MARKS = [\n { keep: true, label: 'kept' },\n { keep: false, label: 'a flash' },\n] as const;\n\n/** the run, grouped the way it was written — which is how it reads */\nconst GROUPS = SUITES.map(([suite]) => ({\n specs: SPECS.filter((s) => s.suite === suite),\n suite,\n}));\n\nexport class Jump extends Component {\n specs = SPECS;\n groups = GROUPS;\n aligns = ALIGNS;\n marks = MARKS;\n\n @tracked align: 'center' | 'start' = 'center';\n /** keep a record of what has been looked at, or just flash it */\n @tracked keep = true;\n @tracked debug = false;\n @tracked target = '';\n /** a new number for every jump, so asking for the same row twice re-runs it */\n @tracked pass = 0;\n /** where in the list of failures the cursor stands; -1 before the first jump */\n @tracked cursor = -1;\n @tracked seen: string[] = [];\n\n get failures() {\n return SPECS.filter((s) => !s.ok);\n }\n\n /**\n * The whole run as sixty ticks, beside the list.\n *\n * This is the thing the stage was missing, and it was missing the point with\n * it. `c.Scroll` is here because a jump should tell you HOW FAR DOWN the run\n * a failure is and whether it sits with the others — and a pane showing eight\n * rows out of sixty gives that travel nothing to mean anything against. You\n * watched a list move and learned nothing from the movement.\n *\n * With the strip, the same scroll is legible: you can see the two failures\n * near the top, the long clean stretch, the three in a row in the middle. The\n * scroll is the answer and the strip is the question it is answering.\n *\n * It is also the fastest way to get anywhere, which is why the ticks are\n * buttons. A minimap you cannot click is a decoration.\n */\n get strip() {\n return SPECS.map((spec) => ({\n cls: this.tickClass(spec),\n id: spec.id,\n label: spec.ok ? spec.name : `${spec.name} — failing`,\n }));\n }\n\n tickClass = (spec: Spec) => {\n const classes = ['jump-tick'];\n if (!spec.ok) {\n classes.push('is-red');\n }\n if (this.seen.includes(spec.id)) {\n classes.push('is-seen');\n }\n if (this.target === spec.id) {\n classes.push('is-at');\n }\n return classes.join(' ');\n };\n\n /** the strip is navigation as well as a picture: go straight to that spec */\n goTo = (id: string) => {\n const at = this.failures.findIndex((f) => f.id === id);\n if (at >= 0) {\n this.cursor = at;\n }\n this.target = id;\n this.pass += 1;\n if (this.keep && !this.seen.includes(id)) {\n this.seen = [...this.seen, id];\n }\n };\n\n failuresIn = (suite: string) =>\n SPECS.filter((s) => s.suite === suite && !s.ok).length;\n\n groupNote = (suite: string) => {\n const n = this.failuresIn(suite);\n const all = SPECS.filter((s) => s.suite === suite).length;\n return n === 0 ? `${all} passing` : `${all} · ${n} failing`;\n };\n\n get triagedCount() {\n return this.seen.length;\n }\n\n get done() {\n return this.seen.length >= this.failures.length;\n }\n\n get tally() {\n const n = this.failures.length;\n if (!this.keep) {\n return `${n} failing`;\n }\n return this.done\n ? `all ${n} triaged`\n : `${n} failing · ${this.triagedCount} triaged`;\n }\n\n /**\n * One label, always. It used to read \"first failure\" until you had pressed it\n * once and \"next failure\" thereafter, which is churn in the one place on the\n * stage that should be a fixed point: the control you press over and over\n * changing its name under your hand reads as something having gone wrong.\n */\n readonly cta = 'next failure';\n\n /** disabled on the first failure, because there is nothing behind it */\n get backOff() {\n return this.cursor <= 0;\n }\n\n /**\n * The cursor walks the failures in order and wraps. It does NOT skip the ones\n * already seen: coming back to a failure you have already read is an ordinary\n * thing to do — you are checking whether the fix took — and a button that\n * silently refuses to revisit is a button that has decided what you meant.\n */\n next = () => {\n const fails = this.failures;\n if (!fails.length) {\n return;\n }\n const at = (this.cursor + 1) % fails.length;\n const spec = fails[at]!;\n this.cursor = at;\n this.target = spec.id;\n this.pass += 1;\n if (this.keep && !this.seen.includes(spec.id)) {\n this.seen = [...this.seen, spec.id];\n }\n };\n\n /**\n * The other half of the transport. A \"next\" with no \"back\" is not a\n * transport, it is a slideshow — and going back to the last failure is what\n * you do the moment you have read the one after it and want to compare.\n */\n back = () => {\n const fails = this.failures;\n if (this.cursor <= 0 || !fails.length) {\n return;\n }\n const at = this.cursor - 1;\n this.cursor = at;\n this.target = fails[at]!.id;\n this.pass += 1;\n };\n\n setAlign = (align: 'center' | 'start') => {\n this.align = align;\n };\n\n /**\n * Turning the record off clears it, rather than hiding it and bringing it\n * back. The control is a statement about how you want to work, and a set of\n * marks that reappear from a previous mood is a set of marks you no longer\n * trust.\n */\n setKeep = (keep: boolean) => {\n if (keep === this.keep) {\n return;\n }\n this.keep = keep;\n if (!keep) {\n this.seen = [];\n }\n };\n\n toggleDebug = () => {\n this.debug = !this.debug;\n };\n\n alignClass = (value: string) =>\n this.align === value ? 'chip is-on' : 'chip';\n\n /**\n * Getters, not methods. `class={{this.keepClass}}` on a zero-argument method\n * renders the FUNCTION — Glimmer only invokes a value that is a helper — and\n * the attribute comes out empty rather than wrong, so the control simply\n * stops showing its state and nothing anywhere reports a problem.\n */\n keepClass = (keep: boolean) =>\n keep === this.keep ? 'jump-opt is-on' : 'jump-opt';\n\n get debugClass() {\n return this.debug ? 'jump-opt is-on' : 'jump-opt';\n }\n\n rowClass = (spec: Spec) => {\n const state = spec.ok ? 'jump-row' : 'jump-row is-red';\n return this.seen.includes(spec.id) ? `${state} is-seen` : state;\n };\n\n <template>\n <div class='ex jump-ex'>\n <div class='jump-pane'>\n {{! Sticky, INSIDE the scrolling pane — which is the thing `c.Raise`\n has to clear. A row scrolled to the top of the pane would sit\n under this bar; raised, it is out of the pane's clip entirely and\n passes over it. }}\n {{! A transport, not a button. The count is the readout, the two\n controls beside it are prev and next, and they are one object\n because they are one idea — move through the failures. }}\n <header class='jump-bar'>\n <span class='jump-tally {{if this.done \"is-done\" \"\"}}'>\n {{this.tally}}\n </span>\n <div class='jump-transport'>\n <button\n type='button'\n class='jump-back'\n aria-label='Previous failure'\n disabled={{this.backOff}}\n {{on 'click' this.back}}\n >◂</button>\n <button\n type='button'\n class='jump-go'\n {{on 'click' this.next}}\n >{{this.cta}}</button>\n </div>\n </header>\n\n {{! Sixty ticks, one per spec: the run at a glance, and the reference\n the scroll's travel is measured against. }}\n <ol class='jump-strip' aria-label='The run'>\n {{#each this.strip key='id' as |tick|}}\n <li>\n <button\n type='button'\n class={{tick.cls}}\n title={{tick.label}}\n aria-label={{tick.label}}\n {{on 'click' (fn this.goTo tick.id)}}\n ></button>\n </li>\n {{/each}}\n </ol>\n\n <Choreo class='jump-region' @debug={{this.debug}} as |c|>\n {{! The run, grouped the way it was written. The suite was on every\n row before, which said \"layout\" eight times in a column and gave\n the run no shape at all. }}\n <ol class='jump-list'>\n {{#each this.groups key='suite' as |group|}}\n <li class='jump-group'>\n <span class='jump-suite'>{{group.suite}}</span>\n <span class='jump-count'>{{this.groupNote group.suite}}</span>\n </li>\n {{#each group.specs key='id' as |spec|}}\n <li\n class={{this.rowClass spec}}\n {{motion id=spec.id role='row'}}\n >\n <span class='jump-dot'></span>\n <span class='jump-name'>{{spec.name}}</span>\n <span class='jump-ms'>{{spec.ms}}ms</span>\n {{#if spec.because}}\n <span class='jump-why'>{{spec.because}}</span>\n {{/if}}\n </li>\n {{/each}}\n {{/each}}\n </ol>\n\n {{! The score is rendered from the first pass, not from the first\n click. A region does not collect a timeline on the pass that\n first renders it — so a score that appears in the same pass as\n the change it describes sits out that change and runs on the\n next one. With an empty target the steps select nothing and cost\n nothing; the first press then has a score waiting for it. }}\n {{#each (array this.pass) key='@identity' as |ask|}}\n {{! The ask number is in the name so that pressing \"next failure\"\n twice on the same row is a different score. A region declines a\n pass whose compiled tree fingerprints identical to the one\n already standing — that is what stops a neighbour's render\n restarting every run on a busy page — and two asks for the same\n spec compile the same tree unless something says which ask this\n is. Pressing it twice on one row is a thing people do. }}\n <c.Sequence @name={{concat 'ask-' ask}}>\n <c.Scroll\n @of={{c.id this.target}}\n @align={{this.align}}\n @duration={{tuneSeconds 'jump' 0.5 'Scroll duration 1'}}\n @ease={{EASE}}\n />\n <c.Parallel>\n {{! only once it has arrived: out of the pane's clip for the\n length of the window, with the shadow that says it left the\n page. Raising DURING the scroll would pin the row to where\n it stood when the lift began and the list would slide out\n from under it. }}\n <c.Raise\n @of={{c.id this.target}}\n @shadow={{true}}\n @duration={{tuneSeconds 'jump' 0.9 'Raise duration 2'}}\n />\n {{! and the mark. `@fill` holds it past the window when the\n marks are being kept, which bridges the flight to the\n render that commits the row's own class — without it the\n highlight drops on the last frame and the class arrives on\n the next one, and the row blinks in between. }}\n {{! An OPAQUE surface, not a tint. The row is out of the\n list's clip and above everything at this moment, and a\n translucent mark lets the rows it is passing over show\n through it — which reads as a rendering fault rather than\n as a lift. Everything else on the list is deliberately a\n little transparent, so the one solid thing is the one\n being pointed at. }}\n <c.Hold\n @of={{c.id this.target}}\n @backgroundColor='var(--bg-elev)'\n @duration={{tuneSeconds 'jump' 0.7 'Hold duration 3'}}\n @fill={{this.keep}}\n />\n </c.Parallel>\n </c.Sequence>\n {{/each}}\n </Choreo>\n </div>\n\n {{! Settings for an instrument, said as a sentence. These were four\n uppercase chips in a row, which is what a control panel looks like\n when it has been bolted under an app rather than built into one — and\n three of them were preferences, not verbs. Verbs get buttons;\n preferences get words you can click. }}\n <div class='jump-set'>\n <span class='jump-key'>land it</span>\n {{#each this.aligns key='value' as |option|}}\n <button\n type='button'\n class={{this.alignClass option.value}}\n {{on 'click' (fn this.setAlign option.value)}}\n >{{option.label}}</button>\n {{/each}}\n\n <span class='jump-key'>marks</span>\n {{#each this.marks key='label' as |option|}}\n <button\n type='button'\n class={{this.keepClass option.keep}}\n {{on 'click' (fn this.setKeep option.keep)}}\n >{{option.label}}</button>\n {{/each}}\n\n <button\n type='button'\n class='{{this.debugClass}} jump-dev'\n title='outline every participant and print the pass'\n {{on 'click' this.toggleDebug}}\n >debug</button>\n </div>\n </div>\n <style scoped>\n .chip {\n border: 1px solid var(--line);\n background: transparent;\n color: var(--ink-dim);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n .chip.is-on {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n\n @media (hover: hover) {\n .chip:hover {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n }\n\n .chip.is-on {\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.35);\n color: var(--ember-hot);\n }\n\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n @media (max-width: 720px) {\n .chip {\n padding: 9px 14px;\n }\n }\n\n /* ---- Triage: nine red in sixty, and not losing your place ---- */\n\n .jump-ex {\n display: flex;\n flex-direction: column;\n gap: 10px;\n width: min(720px, 100%);\n }\n\n /* The pane is the scroll container `c.Scroll` drives and the clip `c.Raise`\n has to escape. Both of those are the point of the stage, so it is a real\n overflow box rather than the page's own scroll. */\n .jump-pane {\n background: rgba(var(--ink-rgb), 0.03);\n border: 1px solid var(--line);\n border-radius: 10px;\n display: grid;\n flex: 1;\n /* the strip, then the run */\n grid-template-columns: 16px minmax(0, 1fr);\n /* and ONE row that cannot grow. Without this the row is sized by its content\n — sixty rows, about 2200px — and the pane stops being a viewport at all:\n the strip stretches to the full height of the run it is supposed to be a\n miniature of. `minmax(0, 1fr)` is the grid spelling of \"take the height you\n were given and no more\". */\n /* the bar, then the run. The bar used to be absolutely positioned OVER the\n list, which is how it managed to be covered by its own content: with\n `land it → at the top` a row scrolls under it, and raising that row —\n which is `c.Raise` doing exactly its job — put it over the count. A bar in\n its own row cannot be covered, and the raise still has the thing that\n matters to escape: the pane's clip. */\n grid-template-rows: auto minmax(0, 1fr);\n min-height: 0;\n overflow: hidden;\n position: relative;\n }\n\n /* The run as sixty ticks. Sixteen pixels of gutter that turn the scroll from\n a movement into a measurement: you can see the two failures near the top, the\n long clean stretch, the three together in the middle — and then watch the\n list travel exactly that far. Without it the pane shows eight rows out of\n sixty and the travel has nothing to mean anything against. */\n .jump-strip {\n background: rgba(var(--ink-rgb), 0.04);\n border-right: 1px solid var(--line);\n grid-row: 1 / -1;\n display: flex;\n flex-direction: column;\n gap: 1px;\n justify-content: center;\n list-style: none;\n margin: 0;\n min-height: 0;\n overflow: hidden;\n padding: 6px 4px;\n }\n\n .jump-strip li {\n display: flex;\n flex: 1;\n max-height: 7px;\n }\n\n .jump-tick {\n background: rgba(var(--ink-rgb), 0.16);\n border: 0;\n border-radius: 1px;\n cursor: pointer;\n display: block;\n flex: 1;\n padding: 0;\n transition:\n background 140ms var(--ease),\n transform 140ms var(--ease);\n }\n\n .jump-tick:hover {\n transform: scaleX(1.6);\n }\n\n .jump-tick.is-red {\n background: var(--ember);\n }\n\n /* a failure that has been looked at: still there, no longer shouting */\n .jump-tick.is-red.is-seen {\n background: rgba(255, 59, 31, 0.4);\n }\n\n .jump-tick.is-at {\n background: var(--copper);\n transform: scaleX(1.8);\n }\n\n .jump-bar {\n align-items: center;\n background: rgba(var(--bg-rgb), 0.7);\n border-bottom: 1px solid var(--line);\n display: flex;\n gap: 10px;\n grid-column: 2;\n padding: 8px 10px;\n }\n\n .jump-tally {\n color: var(--ink-dim);\n flex: 1;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n }\n\n .jump-tally.is-done {\n color: var(--copper-ink);\n }\n\n /* The transport: one object, because prev and next are one idea. Joined rather\n than spaced, with the primary carrying the colour — a pair of equal buttons\n would make going back look like half the point of the stage. */\n .jump-transport {\n display: flex;\n flex: none;\n gap: 1px;\n }\n\n .jump-back,\n .jump-go {\n border: 0;\n cursor: pointer;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.04em;\n padding: 5px 12px;\n transition:\n background 160ms var(--ease),\n color 160ms var(--ease);\n }\n\n .jump-back {\n background: rgba(var(--ink-rgb), 0.08);\n border-radius: 999px 0 0 999px;\n color: var(--ink-dim);\n padding: 5px 10px;\n }\n\n .jump-back:hover:not(:disabled) {\n background: rgba(var(--ink-rgb), 0.14);\n color: var(--ink);\n }\n\n .jump-back:disabled {\n cursor: default;\n opacity: 0.35;\n }\n\n /* Not red. Red on this stage means FAILING — it is on nine rows, nine ticks in\n the strip and every assertion — and a red button is then the loudest thing on\n screen while meaning nothing of the sort. A semantic colour has to keep its\n one job or it stops being semantic. The transport is ink, and the copper\n underline in the settings row is the only other accent. */\n .jump-go {\n background: rgba(var(--ink-rgb), 0.14);\n border-radius: 0 999px 999px 0;\n color: var(--ink);\n }\n\n .jump-go:hover {\n background: rgba(var(--ink-rgb), 0.22);\n }\n\n /* The region sits in the second column, under the bar. */\n .jump-region {\n grid-column: 2;\n grid-row: 2;\n min-height: 0;\n }\n\n /* The region is the positioned wrapper and NOT the scroller.\n The scroll container has to be a descendant, for two reasons that point the\n same way. `c.Scroll` drives the sprite's own scroll container, and the region\n element is not it. And the raise layer lives inside the region — so a region\n that scrolled would carry the raised row along with the list, which is\n exactly what the raise exists to prevent. */\n .jump-region {\n height: 100%;\n position: relative;\n }\n\n .jump-list {\n display: flex;\n flex-direction: column;\n height: 100%;\n list-style: none;\n margin: 0;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 6px 0 12px;\n /* Reserve the scrollbar's channel whether or not there is a scrollbar. Without\n it the box is one width when the list overflows and another when a filter\n leaves it short, and everything inside shifts sideways as you use it. */\n scrollbar-gutter: stable;\n scrollbar-width: thin;\n }\n\n /* A suite header, so the run has a shape. The suite used to be a column on\n every row, which said \"layout\" eight times and told you nothing. */\n .jump-group {\n align-items: baseline;\n color: var(--ink-faint);\n display: flex;\n font-family: var(--font-mono);\n font-size: 9.5px;\n gap: 8px;\n letter-spacing: 0.1em;\n margin: 10px 6px 2px;\n padding: 0 8px 3px;\n text-transform: uppercase;\n }\n\n .jump-group:first-child {\n margin-top: 0;\n }\n\n .jump-suite {\n color: var(--ink-dim);\n }\n\n .jump-count {\n border-top: 1px solid var(--line);\n flex: 1;\n padding-top: 3px;\n text-align: right;\n }\n\n /* At rest a row is a little transparent, so that the one the transport has\n raised — opaque, on its own surface — is unmistakably the one being pointed\n at. A list where everything is solid has to shout to mark one row; a list\n that is quiet only has to stop being quiet. */\n .jump-row {\n align-items: baseline;\n border-radius: 5px;\n color: var(--ink-dim);\n opacity: 0.72;\n display: grid;\n font-size: 11.5px;\n gap: 0 8px;\n grid-template-columns: 8px minmax(0, 1fr) auto;\n margin: 0 6px;\n padding: 5px 8px;\n }\n\n /* A failure reads as a block rather than as a line with a red dot on it: it is\n the thing you are hunting, and it should be findable while the list is\n moving past.\n A tint and a full hairline ring, NOT an inset left bar. A straight band\n fights the corner radius it sits inside — it either stops short and leaves a\n notch or runs into the curve and is clipped into a wedge, and no radius makes\n that look deliberate. A ring follows the shape. */\n .jump-row.is-red {\n background: rgba(255, 59, 31, 0.07);\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.22);\n opacity: 0.92;\n }\n\n /* the pass/fail mark, which is the only colour on a green row */\n .jump-dot {\n background: var(--steel);\n border-radius: 50%;\n height: 6px;\n opacity: 0.5;\n width: 6px;\n }\n\n .jump-row.is-red .jump-dot {\n background: var(--ember);\n opacity: 1;\n }\n\n .jump-suite {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 10px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .jump-name {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .jump-row.is-red .jump-name {\n color: var(--ink);\n }\n\n .jump-ms {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 10px;\n font-variant-numeric: tabular-nums;\n }\n\n /* the assertion, under the name and indented to it */\n .jump-why {\n color: var(--ember);\n font-family: var(--font-mono);\n font-size: 10px;\n grid-column: 2 / -1;\n opacity: 0.85;\n padding-top: 2px;\n }\n\n /* The record. This is app state — a row you have been to — and it is what\n `@fill` bridges to: the hold keeps the mark alive until the render that adds\n this class has landed, so the row does not blink between the two.\n Handled reads as HANDLED: it steps back rather than lighting up. A triaged\n failure is one you no longer need to find. */\n .jump-row.is-seen {\n opacity: 0.45;\n }\n\n .jump-row.is-seen .jump-dot {\n background: transparent;\n box-shadow: inset 0 0 0 1.5px var(--ember);\n }\n\n /* Preferences, as a line of words rather than a row of chips.\n Four uppercase pills under an app is what a control panel looks like when it\n has been bolted on; and three of these are not verbs at all, they are\n choices. A verb earns a button. A choice earns a word you can click, marked\n by weight and colour rather than by a box. */\n .jump-set {\n align-items: baseline;\n color: var(--ink-faint);\n display: flex;\n flex-wrap: wrap;\n font-size: 11px;\n gap: 4px 8px;\n }\n\n .jump-key {\n color: var(--ink-faint);\n font-family: var(--font-mono);\n font-size: 9.5px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n }\n\n .jump-key:not(:first-child) {\n margin-left: 8px;\n }\n\n .jump-opt {\n background: none;\n border: 0;\n border-bottom: 1px solid transparent;\n color: var(--ink-faint);\n cursor: pointer;\n font-size: 11px;\n padding: 0 0 1px;\n transition:\n color 140ms var(--ease),\n border-color 140ms var(--ease);\n }\n\n .jump-opt:hover {\n color: var(--ink-dim);\n }\n\n .jump-opt.is-on {\n border-bottom-color: var(--copper);\n color: var(--ink);\n }\n\n /* honestly a developer control, so it sits apart and stays quiet */\n .jump-dev {\n font-family: var(--font-mono);\n font-size: 10px;\n margin-left: auto;\n }\n\n @container platter (max-width: 430px) {\n .jump-ms {\n display: none;\n }\n }\n @container (max-width: 400px) {\n .jump-ex {\n transform: scale(0.78);\n transform-origin: center;\n }\n }\n </style>\n </template>\n}\n\nexport default Jump;\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSeconds('jump', 0.5, 'Scroll duration 1');\ntuneSeconds('jump', 0.9, 'Raise duration 2');\ntuneSeconds('jump', 0.7, 'Hold duration 3');\n\nexport class JumpDemo extends GalleryDemo {\n static stage = Jump;\n}\n";
454
454
  //#endregion
455
455
  //#region ../choreo-gallery/realm/stages/keyframes.gts?raw
456
- var keyframes_default = "import { motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion, tuneNumber } from '../lib/tuning';\n\nfunction morph() {\n const rotation = tuneNumber('keyframes', 360, 'rotation (deg)', 0, 720, 1);\n const peakScale = tuneNumber(\n 'keyframes',\n 1.14,\n 'peakScale (×)',\n 0.5,\n 2,\n 0.01,\n );\n const lift = tuneNumber('keyframes', 28, 'lift (px)', 0, 80, 1);\n return {\n backgroundColor: ['#ff6a3a', '#e4a35a', '#ff3b1f', '#ff8a4a'],\n borderRadius: ['28%', '50%', '22% 40%', '32%'],\n rotate: [0, rotation / 4, (rotation * 7) / 12, rotation],\n scale: [1, 0.82, peakScale, 1],\n x: [0, 18, -14, 0],\n y: [0, -lift, 10, 0],\n };\n}\nconst glow = {\n opacity: [0.35, 0.7, 0.25, 0.45],\n scale: [0.9, 1.25, 0.8, 1],\n};\nconst transition = {\n duration: 1.35,\n ease: [0.22, 1, 0.36, 1],\n repeat: Infinity,\n} as const;\n\nexport const Keyframes = <template>\n <div class='ex'>\n <div class='morph-stage'>\n <div\n class='morph-glow'\n {{motion\n animate=glow\n transition=(tuneMotion 'keyframes' transition 'transition')\n }}\n ></div>\n <div\n class='morph'\n {{motion\n animate=(morph)\n transition=(tuneMotion 'keyframes' transition 'transition')\n }}\n ></div>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .morph-stage {\n position: relative;\n width: 160px;\n height: 160px;\n display: grid;\n place-items: center;\n }\n\n .morph-glow {\n position: absolute;\n width: 140px;\n height: 140px;\n border-radius: 50%;\n background: radial-gradient(\n circle,\n rgba(255, 59, 31, 0.45),\n transparent 68%\n );\n filter: blur(8px);\n }\n\n .morph {\n position: relative;\n width: 92px;\n height: 92px;\n background: var(--ember-hot);\n box-shadow:\n 0 0 0 1px rgba(255, 255, 255, 0.12) inset,\n 0 22px 50px var(--glow);\n }\n </style>\n</template>;\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneMotion('keyframes', transition, 'transition');\n\nexport class KeyframesDemo extends GalleryDemo {\n static stage = Keyframes;\n}\n";
456
+ var keyframes_default = "import Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { observeStage } from '../lib/onstage';\nimport { tuneMotion, tuneNumber } from '../lib/tuning';\n\nfunction morph() {\n const rotation = tuneNumber('keyframes', 360, 'rotation (deg)', 0, 720, 1);\n const peakScale = tuneNumber(\n 'keyframes',\n 1.14,\n 'peakScale (×)',\n 0.5,\n 2,\n 0.01,\n );\n const lift = tuneNumber('keyframes', 28, 'lift (px)', 0, 80, 1);\n return {\n backgroundColor: ['#ff6a3a', '#e4a35a', '#ff3b1f', '#ff8a4a'],\n borderRadius: ['28%', '50%', '22% 40%', '32%'],\n rotate: [0, rotation / 4, (rotation * 7) / 12, rotation],\n scale: [1, 0.82, peakScale, 1],\n x: [0, 18, -14, 0],\n y: [0, -lift, 10, 0],\n };\n}\nconst glow = {\n opacity: [0.35, 0.7, 0.25, 0.45],\n scale: [0.9, 1.25, 0.8, 1],\n};\nconst transition = {\n duration: 1.35,\n ease: [0.22, 1, 0.36, 1],\n repeat: Infinity,\n} as const;\n\n/** each value at its first keyframe */\nfunction firstOf<T extends object>(frames: T) {\n return Object.fromEntries(\n Object.entries(frames).map(([key, value]) => [\n key,\n Array.isArray(value) ? value[0] : value,\n ]),\n ) as { [K in keyof T]: T[K] extends readonly (infer V)[] ? V : T[K] };\n}\nconst STILL = { duration: 0 } as const;\n\n/**\n * The loop repeats forever and Motion runs it by script, frame by frame, so\n * nothing the stage's resting reaches can pause it. Off stage (scrolled away,\n * or under a demo page) it settles on its first keyframe, where every pass of\n * the loop begins, and the loop stops; it starts again from there when the\n * stage comes back. It mounts playing, as any `{{motion}}` loop does, and a\n * stage that turns out to be off stage settles as soon as that is known.\n *\n * Off is believed only once it has held for two frames. The stage's first\n * report comes before anything has been measured, and says off; the measured\n * answer follows a frame later. Settling on that first word would stop the\n * loop and start it again from its first frame on every mount.\n */\nexport class Keyframes extends Component {\n @tracked private onStage = true;\n\n watch = modifier((el: HTMLElement) => {\n let pending = 0;\n const stage = observeStage(el, (visible) => {\n cancelAnimationFrame(pending);\n pending = 0;\n if (visible) {\n this.onStage = true;\n return;\n }\n pending = requestAnimationFrame(() => {\n pending = requestAnimationFrame(() => {\n pending = 0;\n this.onStage = false;\n });\n });\n });\n return () => {\n cancelAnimationFrame(pending);\n stage.disconnect();\n };\n });\n\n get morph() {\n return this.onStage ? morph() : firstOf(morph());\n }\n\n get glow() {\n return this.onStage ? glow : firstOf(glow);\n }\n\n get transition() {\n return this.onStage\n ? tuneMotion('keyframes', transition, 'transition')\n : STILL;\n }\n\n <template>\n <div class='ex'>\n <div class='morph-stage' {{this.watch}}>\n <div\n class='morph-glow'\n {{motion animate=this.glow transition=this.transition}}\n ></div>\n <div\n class='morph'\n {{motion animate=this.morph transition=this.transition}}\n ></div>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .morph-stage {\n position: relative;\n width: 160px;\n height: 160px;\n display: grid;\n place-items: center;\n }\n\n .morph-glow {\n position: absolute;\n width: 140px;\n height: 140px;\n border-radius: 50%;\n background: radial-gradient(\n circle,\n rgba(255, 59, 31, 0.45),\n transparent 68%\n );\n filter: blur(8px);\n }\n\n .morph {\n position: relative;\n width: 92px;\n height: 92px;\n background: var(--ember-hot);\n box-shadow:\n 0 0 0 1px rgba(255, 255, 255, 0.12) inset,\n 0 22px 50px var(--glow);\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneMotion('keyframes', transition, 'transition');\n\nexport class KeyframesDemo extends GalleryDemo {\n static stage = Keyframes;\n}\n";
457
457
  //#endregion
458
458
  //#region ../choreo-gallery/realm/stages/layout.gts?raw
459
459
  var layout_default = "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { LayoutGroup, motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion } from '../lib/tuning';\nimport LayoutNotes from '../notes/layout';\n\nconst records = [\n {\n artist: 'Night shift',\n id: 'atlas',\n title: 'Atlas',\n wash: 'linear-gradient(160deg, #ff7a45, #7a1408)',\n },\n {\n artist: 'Kiln floor',\n id: 'ember',\n title: 'Ember',\n wash: 'linear-gradient(160deg, #ffb36a, #c42712)',\n },\n {\n artist: 'Cooling rack',\n id: 'flux',\n title: 'Flux',\n wash: 'linear-gradient(160deg, #8b969c, #2a211c)',\n },\n {\n artist: 'Foundry glass',\n id: 'halo',\n title: 'Halo',\n wash: 'linear-gradient(160deg, #f3ece3, #9c6b3c)',\n },\n];\n\nconst curves = {\n bounce: { bounce: 0.48, type: 'spring', visualDuration: 0.5 },\n ease: { duration: 0.42, ease: [0.22, 1, 0.36, 1] },\n linear: { duration: 0.4, ease: 'linear' },\n snap: { bounce: 0, type: 'spring', visualDuration: 0.18 },\n} as const;\n\nconst names = ['snap', 'bounce', 'ease', 'linear'] as const;\n\ntype Curve = (typeof names)[number];\n\nexport class LayoutToggle extends Component {\n @tracked grid = false;\n @tracked curve: Curve = 'bounce';\n\n get transition() {\n return curves[this.curve];\n }\n\n toggle = () => {\n this.grid = !this.grid;\n };\n\n pick = (curve: Curve) => {\n this.curve = curve;\n this.grid = !this.grid;\n };\n\n <template>\n <LayoutGroup>\n <div class='ex'>\n <div class='curve-row'>\n {{#each names as |name|}}\n <button\n type='button'\n class={{if (isOn name this.curve) 'chip is-on' 'chip'}}\n {{on 'click' (fn this.pick name)}}\n >{{name}}</button>\n {{/each}}\n </div>\n <button type='button' class='replay' {{on 'click' this.toggle}}>\n {{if this.grid 'List' 'Grid'}}\n </button>\n <div class={{if this.grid 'shelf is-grid' 'shelf is-list'}}>\n {{#each records as |record|}}\n <div\n class='record'\n {{motion\n layout=true\n transition=(tuneMotion 'layout' this.transition 'transition')\n }}\n >\n <div\n class='record-art'\n {{motion\n layout=true\n style=(artStyle record.wash)\n transition=(tuneMotion 'layout' this.transition 'transition')\n }}\n ></div>\n <div\n class='record-meta'\n {{motion\n layout='position'\n transition=(tuneMotion 'layout' this.transition 'transition')\n }}\n >\n <strong>{{record.title}}</strong>\n <small>{{record.artist}}</small>\n </div>\n </div>\n {{/each}}\n </div>\n </div>\n </LayoutGroup>\n <style scoped>\n .chip {\n border: 1px solid var(--line);\n background: transparent;\n color: var(--ink-dim);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 11px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n }\n\n .chip.is-on {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n\n @media (hover: hover) {\n .chip:hover {\n color: var(--ink);\n border-color: var(--line-strong);\n background: var(--bg-spot);\n }\n }\n\n .chip.is-on {\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.35);\n color: var(--ember-hot);\n }\n\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .replay {\n position: absolute;\n top: 14px;\n right: 14px;\n z-index: 3;\n border: 1px solid var(--line-strong);\n background: rgba(var(--bg-rgb), 0.72);\n backdrop-filter: blur(8px);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .replay:hover {\n color: var(--ink);\n }\n }\n\n .ex:has(.curve-row) {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: safe center;\n padding: 72px 16px 20px;\n }\n\n .curve-row {\n position: absolute;\n top: 14px;\n left: 14px;\n right: 96px;\n z-index: 3;\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n max-width: none;\n }\n\n .shelf {\n width: min(88%, 300px);\n }\n\n .shelf.is-list {\n display: flex;\n flex-direction: column;\n gap: 10px;\n }\n\n /**\n * Grid mode is the one that can overflow, so it is bounded on BOTH axes\n * against the stage rather than only by width. `.ex` is a size container, and\n * the third term is this layout's own arithmetic: two columns of (w − 10)/2,\n * a ~38px caption under each, twice over, plus the row gap — which comes to\n * w + 78. Holding that under 76% of the stage height leaves room for the curve\n * picker above and keeps the second row on screen at any size.\n *\n * The class selector is deliberate: a narrow-stage rule below widens `.shelf`\n * to fill its container, and this has to outrank it.\n */\n .shelf.is-grid {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 12px 10px;\n width: min(88cqw, 300px, calc(86cqh - 60px));\n }\n\n .record {\n display: flex;\n align-items: center;\n gap: 12px;\n }\n\n .shelf.is-grid .record {\n flex-direction: column;\n align-items: stretch;\n gap: 8px;\n }\n\n .record-art {\n width: 56px;\n height: 56px;\n border-radius: 14px;\n flex: none;\n box-shadow: 0 10px 22px\n rgba(var(--shadow-rgb), calc(0.35 * var(--shadow-a)));\n }\n\n .shelf.is-grid .record-art {\n width: 100%;\n height: auto;\n aspect-ratio: 1;\n border-radius: 16px;\n }\n\n .record-meta strong {\n display: block;\n font-family: var(--font-display);\n font-size: clamp(12px, 3.4cqw, 16px);\n letter-spacing: -0.03em;\n }\n\n .record-meta small {\n color: var(--ink-dim);\n font-size: clamp(10px, 2.6cqw, 12px);\n }\n\n @container (max-width: 420px) {\n .ex:has(.curve-row) {\n padding-top: 78px;\n }\n\n .curve-row {\n right: 80px;\n }\n\n .curve-row .chip {\n padding: 5px 8px;\n font-size: 10px;\n }\n\n .shelf {\n width: min(92%, 100%);\n max-width: 100%;\n }\n }\n\n @media (max-width: 720px) {\n .chip {\n padding: 9px 14px;\n }\n }\n </style>\n </template>\n}\n\nfunction isOn(name: Curve, curve: Curve) {\n return name === curve;\n}\n\nfunction artStyle(wash: string) {\n return { background: wash };\n}\n\nexport class LayoutToggleDemo extends GalleryDemo {\n static stage = LayoutToggle;\n static notes = LayoutNotes;\n}\n";
@@ -510,10 +510,10 @@ var pointer_default = "import { on } from '@ember/modifier';\nimport Component f
510
510
  var presence_default = "import type { TOC } from '@ember/component/template-only';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { LayoutGroup, motion, Presence } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion } from '../lib/tuning';\nimport PresenceNotes from '../notes/presence';\n\nconst keyOf = (item: { id: string }) => item.id;\n\nconst notices = [\n {\n detail: 'main · 12 files',\n id: 'pass',\n title: 'Build passed',\n tone: 'pass',\n },\n { detail: '3 assertions', id: 'fail', title: 'Tests failed', tone: 'fail' },\n] as const;\n\nconst initial = { opacity: 0, y: 14 };\nconst animate = { opacity: 1, y: 0 };\n/**\n * The leaver's exit is short on purpose, and shorter than it looks like it\n * needs to be.\n *\n * `Presence` unmounts a leaver when its exit finishes — the LONGEST of the\n * properties in it. With `y` on a 0.4s spring and opacity on a 0.28s tween,\n * the notice went fully invisible and then went on holding its slot in flow\n * for the rest of the spring. In `sync` that gap is not free: the newcomer is\n * laid out below it, so what you saw was the whole column shoved down by the\n * height of something that was no longer there, and shoved back when it\n * finally unmounted.\n *\n * Stacking IS sync — the newcomer belongs below a leaver that is still in\n * flow, and that is the entire difference between this mode and popLayout.\n * The invisible half of it was not. So `y` now lands with the fade.\n */\nconst exit = { opacity: 0, y: -18 };\nconst transition = {\n opacity: { duration: 0.24 },\n y: { bounce: 0.1, type: 'spring', visualDuration: 0.26 },\n} as const;\nconst restMove = {\n bounce: 0.12,\n type: 'spring',\n visualDuration: 0.28,\n} as const;\n\nconst modes = [\n { hint: 'Both at once', id: 'sync' },\n { hint: 'Exit first', id: 'wait' },\n { hint: 'Space collapses', id: 'popLayout' },\n] as const;\n\ntype Mode = (typeof modes)[number]['id'];\ntype Notice = (typeof notices)[number];\n\nexport class PresenceModes extends Component {\n @tracked index = 0;\n\n get items(): Notice[] {\n return [notices[this.index]!];\n }\n\n swap = () => {\n this.index = this.index === 0 ? 1 : 0;\n };\n\n <template>\n <div class='ex'>\n <button\n type='button'\n class='replay'\n {{on 'click' this.swap}}\n >Switch</button>\n <div class='modes'>\n {{#each modes as |mode|}}\n <ModeColumn\n @mode={{mode.id}}\n @hint={{mode.hint}}\n @items={{this.items}}\n />\n {{/each}}\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .replay {\n position: absolute;\n top: 14px;\n right: 14px;\n z-index: 3;\n border: 1px solid var(--line-strong);\n background: rgba(var(--bg-rgb), 0.72);\n backdrop-filter: blur(8px);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .replay:hover {\n color: var(--ink);\n }\n }\n\n /* Sized in CONTAINER units, not a pixel cap.\n *\n * `.ex` is a size container and most of the gallery scales with it, but this\n * grid was min(94%, 620px) — so it filled 91% of a gallery tile and only 53%\n * of the full-width stage. The Magic Move between the two stretches both\n * snapshots into one box and crosses them, and two copies of the same three\n * panels at wildly different fractions is exactly the doubled, almost-aligned\n * ghosting you see mid-flight. Holding one fraction at both sizes is what\n * makes the crossfade land on itself. cqh keeps it from going absurdly wide\n * on a short, wide stage. */\n .modes {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: clamp(8px, 2cqw, 18px);\n width: min(92cqw, 260cqh);\n }\n @container (max-width: 560px) {\n .modes {\n gap: 8px;\n width: 96%;\n }\n }\n\n @container (max-width: 420px) {\n .modes {\n width: min(94%, 100%);\n gap: 6px;\n }\n }\n\n @container (max-width: 280px) {\n .modes {\n gap: 4px;\n }\n }\n\n @media (max-width: 720px) {\n .modes {\n width: min(94%, 100%);\n gap: 6px;\n }\n }\n </style>\n </template>\n}\n\nconst ModeColumn = <template>\n <div class='mode'>\n <LayoutGroup>\n <div class='mode-slot'>\n <Presence\n @items={{@items}}\n @key={{keyOf}}\n @mode={{@mode}}\n @initial={{false}}\n as |notice h|\n >\n {{! layout=true matters most in `sync`. There, the leaver stays in\n flow for its whole exit, so the newcomer is laid out BELOW it and\n then has to get to the top once the leaver goes — without this it\n snaps that whole distance in a single frame. With it, the rise is\n animated, which is also what makes the three modes legible side\n by side: sync travels, wait does not move at all, and popLayout\n is already home because its leaver left flow immediately. }}\n <article\n class={{if (isFail notice) 'toast is-fail' 'toast is-pass'}}\n {{motion\n presence=h\n layout=true\n initial=initial\n animate=animate\n exit=exit\n transition=(tuneMotion 'presence' transition 'transition')\n }}\n >\n <span class='toast-mark' aria-hidden='true'></span>\n <span class='toast-copy'>\n <b>{{notice.title}}</b>\n <span>{{notice.detail}}</span>\n </span>\n </article>\n </Presence>\n <div\n class='mode-rest'\n {{motion\n layout=true\n transition=(tuneMotion 'presence' restMove 'restMove')\n }}\n >\n Up next\n </div>\n </div>\n </LayoutGroup>\n <span class='mode-name'>{{@mode}}</span>\n <span class='mode-hint'>{{@hint}}</span>\n </div>\n <style scoped>\n .mode {\n display: flex;\n flex-direction: column;\n gap: 10px;\n align-items: stretch;\n }\n\n .mode-slot {\n position: relative;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: 8px;\n /* Fixed, not min-height. `sync` briefly holds BOTH notices in flow and grows\n to ~192px while the other two stay at ~123px — so the three frames would\n shift against each other and you could not tell which difference was the\n mode and which was the reflow. The frame is frozen at the tallest case;\n what still moves inside it is exactly the thing being demonstrated. */\n height: 196px;\n padding: 10px;\n border: 1px solid var(--line);\n border-radius: 20px;\n background: linear-gradient(180deg, var(--bg-spot), var(--bg-well));\n overflow: visible;\n }\n\n .toast {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n padding: 12px;\n border-radius: 16px;\n background: var(--bg-spot);\n border: 1px solid var(--line-strong);\n box-shadow: 0 14px 30px\n rgba(var(--shadow-rgb), calc(0.45 * var(--shadow-a)));\n }\n\n .toast.is-fail {\n background: color-mix(in srgb, var(--steel) 14%, var(--bg-spot));\n border-color: rgba(139, 150, 156, 0.28);\n }\n\n .toast.is-fail .toast-mark {\n background:\n radial-gradient(circle at 30% 25%, #fff4, transparent 42%),\n linear-gradient(160deg, var(--steel), var(--bg-spot));\n box-shadow: none;\n }\n\n .mode-rest {\n /* Pinned to the floor of the slot, which is a fixed height already.\n It used to sit immediately under the notices, so `sync` — the one mode\n that briefly holds TWO — swung it 63px down and 63px back on every\n switch. That excursion was the mode's layout being honest, and it read\n as the panel lurching. Reserving the space instead means the only thing\n that moves is the notice, which is the thing the mode is about: the\n newcomer still enters BELOW the leaver and still rises as it unmounts. */\n margin-top: auto;\n padding: 8px 12px;\n border-radius: 12px;\n background: var(--bg-spot);\n border: 1px dashed var(--line);\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .toast-mark {\n /* scaled with the stage like the type: at a gallery card's width the mark is\n the one thing competing with the title for room, and the colour reads just\n as well small */\n width: clamp(10px, 3.4cqw, 28px);\n height: clamp(10px, 3.4cqw, 28px);\n border-radius: clamp(3px, 1cqw, 8px);\n background:\n radial-gradient(circle at 30% 25%, #fff6, transparent 42%),\n linear-gradient(160deg, var(--ember-hot), var(--ember));\n box-shadow: 0 0 12px var(--glow);\n flex: none;\n }\n\n /* The stage is a container (`.ex` sets container-type), so the type tracks the\n width it actually has rather than stepping at a breakpoint. Three columns in\n a gallery card are ~80px wide and the same three on the demo page are ~200px;\n one fixed size cannot serve both, and a title that overflows a fixed-height\n frame spills over whatever is beneath it. */\n .toast-copy b {\n display: -webkit-box;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n font-size: clamp(10px, calc(6px + 1.6cqw), 13px);\n line-height: 1.25;\n letter-spacing: -0.02em;\n }\n\n .toast-copy span {\n display: -webkit-box;\n -webkit-line-clamp: 2;\n line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n color: var(--ink-dim);\n font-size: clamp(9px, calc(5px + 1.2cqw), 11px);\n line-height: 1.35;\n }\n\n .mode-name {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n text-align: center;\n }\n\n .mode-hint {\n margin-top: -6px;\n font-size: 11px;\n color: var(--ink-faint);\n text-align: center;\n }\n @container (max-width: 560px) {\n .mode-slot {\n /* recomputed for the compact toast: sync still has to hold two of them\n plus 'Up next' without the frame changing size */\n height: 132px;\n padding: 8px;\n border-radius: 14px;\n gap: 6px;\n }\n\n .toast {\n padding: 7px 8px;\n border-radius: 11px;\n gap: 6px;\n }\n\n /* the detail line is the one thing that carries nothing the mode comparison\n needs, so it is what goes */\n .toast-copy span,\n .mode-hint {\n display: none;\n }\n\n /* the title may wrap to the two lines the slot was measured for; the clamp\n on the base rule is what stops a third line spilling out of the frame */\n .toast-copy {\n min-width: 0;\n }\n\n .mode-rest {\n padding: 6px 8px;\n font-size: 9px;\n letter-spacing: 0.06em;\n }\n\n .mode-name {\n font-size: 9px;\n letter-spacing: 0.08em;\n }\n }\n\n @container (max-width: 420px) {\n .mode-slot {\n padding: 8px;\n gap: 6px;\n }\n\n .mode-name {\n letter-spacing: 0.04em;\n font-size: 9px;\n }\n\n .mode-hint {\n display: none;\n }\n\n .toast {\n padding: 8px;\n gap: 8px;\n }\n\n .toast-copy span {\n display: none;\n }\n }\n\n @container (max-width: 280px) {\n .toast-mark {\n display: none;\n }\n }\n\n .choreo-site:not([data-theme='light']) .mode-slot {\n background: linear-gradient(180deg, #2e2925, #221e1a);\n }\n\n .choreo-site:not([data-theme='light']) .toast {\n background: #403830;\n }\n\n .choreo-site:not([data-theme='light']) .toast.is-fail {\n background: #2e3134;\n }\n\n .choreo-site:not([data-theme='light']) .toast.is-fail .toast-mark {\n background:\n radial-gradient(circle at 30% 25%, #fff4, transparent 42%),\n linear-gradient(160deg, #8b969c, #3a322c);\n }\n\n .choreo-site:not([data-theme='light']) .mode-rest {\n background: #312c27;\n }\n </style>\n</template> satisfies TOC<{\n Args: { hint: string; items: Notice[]; mode: Mode };\n}>;\n\nfunction isFail(notice: Notice) {\n return notice.tone === 'fail';\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneMotion('presence', transition, 'transition');\ntuneMotion('presence', restMove, 'restMove');\n\nexport class PresenceModesDemo extends GalleryDemo {\n static stage = PresenceModes;\n static notes = PresenceNotes;\n}\n";
511
511
  //#endregion
512
512
  //#region ../choreo-gallery/realm/stages/presentation.gts?raw
513
- var presentation_default = "import type { ChoreoContext } from '@cardstack/choreo';\nimport { beacon, Choreo } from '@cardstack/choreo';\nimport type { TOC } from '@ember/component/template-only';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, Presence } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion, tuneSeconds } from '../lib/tuning';\nimport PresentationNotes from '../notes/presentation';\n\nconst IN = 0.32;\n/** the beat one element trails another INSIDE a single gate — the whole\n * reason the deck is ten clicks and not twenty: things that belong to the\n * same thought arrive together, a breath apart, on one press. */\nconst CHASE = 0.16;\nconst GROUP = 0.4;\nconst FLIGHT = 1.18;\nconst PULSE = 0.46;\nconst GLIDE = [0.22, 1, 0.36, 1] as const;\nconst LAST = 4;\nconst SLIDES = [0, 1, 2, 3, 4] as const;\nconst XFADE_T = { duration: 0.48, ease: [0.22, 1, 0.36, 1] } as const;\n\n/* ── the figure ──────────────────────────────────────────────────────────\n *\n * One drawing, built from real geometry rather than a hand-waved squiggle.\n * It has exactly one path the timeline draws (@pathLength) and one bead the\n * timeline flies (c.Move + @path), so the art and the score are the same\n * object seen twice. The title slide deliberately has no figure at all —\n * type, scale and colour carry it.\n */\n\n/** the route: five nodes, one polyline, drawn left to right */\nconst HOP =\n 'M 24 82 C 46 50, 62 32, 88 29 S 128 69, 152 75 S 194 31, 220 25 S 264 54, 292 64';\n/** the packet's flight along it, relative to the first node */\nconst HOP_RIDE =\n 'M 0 0 C 22 -32, 38 -50, 64 -53 S 104 -13, 128 -7 S 170 -51, 196 -57 S 240 -28, 268 -18';\n\n/** where the mesh's nodes sit, and the hue each one carries */\nconst NODES = [\n { cx: 24, cy: 82, key: 'n0', tone: 'is-halo' },\n { cx: 88, cy: 29, key: 'n1', tone: 'is-flux' },\n { cx: 152, cy: 75, key: 'n2', tone: 'is-halo' },\n { cx: 220, cy: 25, key: 'n3', tone: 'is-flux' },\n { cx: 292, cy: 64, key: 'n4', tone: 'is-ember' },\n] as const;\n\nconst DAYS = [\n { id: 's2-d0', name: 'SFO' },\n { id: 's2-d1', name: 'ORD' },\n { id: 's2-d2', name: 'DUB' },\n { id: 's2-d3', name: 'FRA' },\n { id: 's2-d4', name: 'SIN' },\n] as const;\n\nconst LINES = [\n {\n copy: 'c-a',\n id: 'cell-a',\n n: '01',\n rule: 'r-a',\n text: 'Nothing waits twice.',\n tick: 't-a',\n },\n {\n copy: 'c-b',\n id: 'cell-b',\n n: '02',\n rule: 'r-b',\n text: 'The edge is the middle.',\n tick: 't-b',\n },\n {\n copy: 'c-c',\n id: 'cell-c',\n n: '03',\n rule: 'r-c',\n text: 'Distance is a decision.',\n tick: 't-c',\n },\n] as const;\n\nconst MOVES = [\n {\n copy: 'm1-copy',\n id: 'm1',\n n: '01',\n name: 'Edge',\n num: 'm1-num',\n rule: 'm1-rule',\n text: 'Compute where it lands.',\n title: 'm1-title',\n },\n {\n copy: 'm2-copy',\n id: 'm2',\n n: '02',\n name: 'Mesh',\n num: 'm2-num',\n rule: 'm2-rule',\n text: 'Every node a front door.',\n title: 'm2-title',\n },\n {\n copy: 'm3-copy',\n id: 'm3',\n n: '03',\n name: 'Seal',\n num: 'm3-num',\n rule: 'm3-rule',\n text: 'Signed at the boundary.',\n title: 'm3-title',\n },\n] as const;\n\n/** the ground each slide is printed on, in order */\nconst GROUNDS = ['is-atlas', 'is-paper', 'is-split', 'is-flux', 'is-ember'];\n\ntype Xfade = 'fade' | 'push' | 'rise' | 'scale';\nconst XFADES: Xfade[] = ['fade', 'rise', 'scale', 'push'];\n\nconst pageKey = (page: { id: number; pass: number }) =>\n `${page.id}.${page.pass}`;\n\ntype SlideHost = HTMLElement & {\n choreoCtx?: ChoreoContext;\n innerCtx?: ChoreoContext;\n presentation?: Presentation;\n};\n/** inner Presence never leaves — so Choreo participants do not register as\n * the slide plate's exit children (they have no `exit` of their own). */\nconst HOLD = [{ id: 'hold' }];\nconst holdKey = (item: { id: string }) => item.id;\n\n/**\n * MESH — the route figure.\n *\n * Five nodes and the one hop that joins them. The faint chords are the paths\n * NOT taken; the bright polyline is the one the packet rides.\n */\nconst Mesh: TOC<{\n Args: { lineId: string };\n Element: SVGSVGElement;\n}> = <template>\n <svg class='pres-fig' viewBox='0 0 320 110' aria-hidden='true' ...attributes>\n <path\n class='pres-chord'\n d='M24 82 L152 75 M88 29 L220 25 M152 75 L292 64 M24 82 L88 29'\n />\n <path class='pres-ghost' d={{HOP}} />\n <path\n class='pres-stroke is-build'\n d={{HOP}}\n {{motion id=@lineId role='build'}}\n />\n {{#each NODES as |n|}}\n <circle class='pres-node {{n.tone}}' cx={{n.cx}} cy={{n.cy}} r='4.5' />\n <circle class='pres-node-ring' cx={{n.cx}} cy={{n.cy}} r='9' />\n {{/each}}\n </svg>\n <style scoped>\n /* builds start invisible; the timeline writes them. Back-arrive skips the\n timeline and shows the still — the previous slide, already built. */\n .pres-slide .is-build {\n opacity: 0;\n }\n\n .pres-slide[data-arrive='back'] .is-build {\n opacity: 1;\n transform: none;\n }\n\n /* The split is the one ground where a single ink cannot serve the slide: the\n figure runs across the cut, so anything derived from --sl-ink (dark, for\n the paper half) disappears into the steel half — black on black, the exact\n trap. These take a MID tone instead, which holds on cream and on steel. */\n .pres-slide.is-split .pres-ghost,\n .pres-slide.is-split .pres-chord,\n .pres-slide.is-split .pres-node-ring {\n stroke: rgba(140, 138, 134, 0.62);\n }\n\n /* ---- the figures ---- */\n\n .pres-fig {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n overflow: visible;\n }\n\n .pres-ghost,\n .pres-stroke {\n fill: none;\n stroke-width: 2.2;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n /* the path not yet drawn: the same line, at a whisper */\n .pres-ghost {\n stroke: color-mix(in srgb, var(--sl-ink) 16%, transparent);\n }\n\n /* the one line the timeline pulls on with @pathLength */\n .pres-stroke {\n stroke: var(--sl-accent);\n filter: drop-shadow(\n 0 0 6px color-mix(in srgb, var(--sl-accent) 45%, transparent)\n );\n }\n\n /* MESH — the chords are the routes NOT taken */\n .pres-chord {\n fill: none;\n stroke: color-mix(in srgb, var(--sl-ink) 10%, transparent);\n stroke-width: 1;\n stroke-dasharray: 3 4;\n }\n\n .pres-node-ring {\n fill: none;\n stroke: color-mix(in srgb, var(--sl-ink) 18%, transparent);\n stroke-width: 1;\n }\n\n /* the accent set — Halo, Flux and Ember as marks. The deck's one place for\n more than a single hue, and all three come from the brand's own ramps. */\n .pres-node.is-halo {\n fill: #edce9e;\n }\n\n .pres-node.is-flux {\n fill: #9aa2a8;\n }\n\n .pres-node.is-ember {\n fill: #f2843e;\n }\n </style>\n</template>;\n\n/**\n * Inner region: first render compiles no timeline, so the score waits one\n * frame for participants to exist, then plays. Outer never sees these ids.\n */\nclass InnerScene extends Component<{\n Blocks: { default: [ChoreoContext] };\n}> {\n @tracked go = false;\n boot = modifier(() => {\n const id = requestAnimationFrame(() => {\n this.go = true;\n });\n return () => cancelAnimationFrame(id);\n });\n\n <template>\n <Choreo class='pres-inner' {{this.boot}} as |c|>\n {{#if this.go}}\n {{yield c}}\n {{/if}}\n </Choreo>\n <style scoped>\n .pres-inner {\n position: absolute;\n inset: 0;\n pointer-events: none;\n }\n </style>\n </template>\n}\n\n/**\n * A presentation is a run that parks — and a deck is five of those, in a row.\n *\n * TEN CLICKS, end to end. Six of them open a gate; four turn the page. That\n * budget is the whole design: anything that belongs to one thought arrives on\n * one press, a breath apart (see CHASE), rather than dribbling out a beat per\n * click. The week on slide 03 used to be five clicks and is now one @stagger;\n * the third verse and the third move each chase the second instead of asking\n * again. Only the title slide gets two gates — it is the opening, it earns\n * the extra beat — and the deck accelerates from there to a single-gate close.\n *\n * Click the plate to open the next gate on this slide. Mash mid-build and the\n * segment completes rather than skipping. When the slide is done, the same\n * click turns the page. The arrows in the imprint change slides outright; they\n * do not play builds. Back shows the previous slide already built.\n *\n * Keys (once the deck is focused — the copper hairline): Space is a click,\n * < / > (and the arrow keys) turn the page.\n *\n * Slides crossfade through Presence (sync — leaver and newcomer together).\n * The fade kind is a control on the imprint. Slides 02 and 04 keep a nested\n * <Choreo> for the verse / the system: an inner region with its own score,\n * a preamble that plays itself, then a gate you click.\n */\nexport class Presentation extends Component {\n @tracked live = false;\n @tracked slide = 0;\n @tracked pass = 0;\n @tracked arrive: 'back' | 'fwd' = 'fwd';\n @tracked xfade: Xfade = 'fade';\n private seen = false;\n private c: ChoreoContext | null = null;\n private inner: ChoreoContext | null = null;\n private deck: HTMLElement | null = null;\n\n wire = modifier((el: HTMLElement, [c]: [ChoreoContext]) => {\n this.c = c;\n const host = el.closest('.pres-slide') as SlideHost | null;\n if (host) {\n host.presentation = this;\n host.choreoCtx = c;\n }\n return () => {\n if (this.c === c) {\n this.c = null;\n }\n if (host) {\n delete host.presentation;\n delete host.choreoCtx;\n }\n };\n });\n\n wireInner = modifier((el: HTMLElement, [c]: [ChoreoContext]) => {\n this.inner = c;\n const host = el.closest('.pres-slide') as SlideHost | null;\n if (host) {\n host.innerCtx = c;\n }\n return () => {\n if (this.inner === c) {\n this.inner = null;\n }\n if (host && host.innerCtx === c) {\n delete host.innerCtx;\n }\n };\n });\n\n /** the plate on top — during a crossfade the leaver still has a run */\n private plate(): SlideHost | null {\n return (this.deck?.querySelector(\n '.pres-leaf:not(.is-leaving) .pres-slide',\n ) ?? this.deck?.querySelector('.pres-slide')) as SlideHost | null;\n }\n\n boot = modifier((el: HTMLElement) => {\n this.deck = el;\n const id = requestAnimationFrame(() => {\n if (!this.seen) {\n this.seen = true;\n this.live = true;\n }\n });\n return () => {\n cancelAnimationFrame(id);\n this.deck = null;\n };\n });\n\n /** `pass` is in the key, not just the id: Start Over from slide 0 has to\n * remount the plate, and a key that only names the slide would call that\n * the same page and keep the built stills exactly as they were. */\n get pages() {\n return [{ id: this.slide, pass: this.pass }];\n }\n\n get atStart() {\n return this.slide <= 0;\n }\n\n get atEnd() {\n return this.slide >= LAST;\n }\n\n get folio() {\n return `${String(this.slide + 1).padStart(2, '0')} / 05`;\n }\n\n get building() {\n return this.arrive === 'fwd';\n }\n\n get xfadeIn() {\n switch (this.xfade) {\n case 'rise':\n return { opacity: 0, y: '68%' };\n case 'scale':\n return { opacity: 0, scale: 0.72 };\n case 'push':\n return { opacity: 1, x: this.arrive === 'fwd' ? '100%' : '-100%' };\n default:\n return { opacity: 0 };\n }\n }\n\n get xfadeOn() {\n return { opacity: 1, scale: 1, x: 0, y: 0 };\n }\n\n get xfadeOut() {\n switch (this.xfade) {\n case 'rise':\n return { opacity: 0, y: '-46%' };\n case 'scale':\n return { opacity: 0, scale: 1.24 };\n case 'push':\n return { opacity: 1, x: this.arrive === 'fwd' ? '-100%' : '100%' };\n default:\n return { opacity: 0 };\n }\n }\n\n get xfadeT() {\n return XFADE_T;\n }\n\n is = (n: number) => this.slide === n;\n isPage = (page: { id: number }, n: number) => page.id === n;\n\n /**\n * Five slides, four grounds. A deck that paints every slide the same colour\n * is a document; the change of ground IS the punctuation, so the deck runs\n * Atlas, paper, a split, Flux, Ember. Each ground carries its own ink and\n * its own accent, and every element on the slide reads them off the ground\n * rather than naming a hue itself.\n */\n slideClass = (page: { id: number }) => {\n const ground = GROUNDS[page.id] ?? 'is-atlas';\n return page.id === 0\n ? `pres-slide is-title ${ground}`\n : `pres-slide ${ground}`;\n };\n\n tap = () => {\n this.setHot(true);\n this.deck?.focus({ preventScroll: true });\n if (!this.live) {\n this.live = true;\n return;\n }\n const plate = this.plate();\n const inner = plate?.innerCtx?.run ?? this.inner?.run;\n if (inner && !inner.isDone()) {\n inner.advance();\n return;\n }\n const run = plate?.choreoCtx?.run ?? this.c?.run;\n if (run && !run.isDone()) {\n run.advance();\n return;\n }\n this.fwd();\n };\n\n /** `>` — the same verb the plate has: open the next gate, or turn the page */\n stepFwd = (event?: Event) => {\n event?.stopPropagation();\n this.tap();\n };\n\n /**\n * `<` — one build back, innermost first. A nested region is the last thing\n * that moved, so it is the first thing to un-move; only when neither run has\n * a segment behind it does this fall back to the previous slide.\n */\n stepBack = (event?: Event) => {\n event?.stopPropagation();\n this.setHot(true);\n this.deck?.focus({ preventScroll: true });\n const plate = this.plate();\n const inner = plate?.innerCtx?.run ?? this.inner?.run ?? null;\n // retreat() is the engine's Keynote rule: land parked at the previous\n // gate, everything ahead re-closed, and HOLD — no self-open, nothing\n // plays until the next advance replays the segment forward. The\n // binary-search scrub this replaces landed a millisecond shy of the\n // gate with the gate still open and the transport still willing: a\n // @delay gate re-opened itself, and a forward step skipped the\n // un-built segment instead of playing it.\n if (inner?.retreat()) {\n return;\n }\n const outer = plate?.choreoCtx?.run ?? this.c?.run ?? null;\n if (outer?.retreat()) {\n return;\n }\n this.back(event);\n };\n\n // `{{on}}` types every handler in a realm as taking a plain `Event`\n keys = (e: Event) => {\n const event = e as KeyboardEvent;\n if (event.repeat) {\n return;\n }\n switch (event.key) {\n case ' ':\n event.preventDefault();\n this.tap();\n break;\n case '>':\n case '.':\n case 'ArrowRight':\n event.preventDefault();\n this.stepFwd(event);\n break;\n case '<':\n case ',':\n case 'ArrowLeft':\n event.preventDefault();\n this.stepBack(event);\n break;\n }\n };\n\n /** classList, not tracked: writing `hot` re-renders the region, and an\n * in-flight path looks like a moved sprite, which would restart the run. */\n private setHot(on: boolean) {\n this.deck?.classList.toggle('is-hot', on);\n }\n\n grab = (event: Event) => {\n this.setHot(true);\n (event.currentTarget as HTMLElement).focus({ preventScroll: true });\n };\n\n onFocus = () => {\n this.setHot(true);\n };\n\n onBlur = (e: Event) => {\n const event = e as FocusEvent;\n const next = event.relatedTarget as Node | null;\n if (next && (event.currentTarget as HTMLElement).contains(next)) {\n return;\n }\n this.setHot(false);\n };\n\n /** the bar sits inside the deck, and the deck's own click takes focus —\n * a control in it has to keep its press to itself */\n stop = (event: Event) => {\n event.stopPropagation();\n };\n\n isFade = (kind: Xfade) => this.xfade === kind;\n\n pickFade = (event: Event) => {\n event.stopPropagation();\n this.xfade = (event.target as HTMLSelectElement).value as Xfade;\n };\n\n turn = (dir: 'back' | 'fwd') => {\n this.arrive = dir;\n this.inner = null;\n };\n\n go = (n: number, event?: Event) => {\n event?.stopPropagation();\n this.setHot(true);\n this.deck?.focus({ preventScroll: true });\n if (n === this.slide || n < 0 || n > LAST) {\n return;\n }\n this.turn(n < this.slide ? 'back' : 'fwd');\n this.slide = n;\n };\n\n fwd = (event?: Event) => {\n this.go(this.slide + 1, event);\n };\n\n /** back to slide 01, unbuilt — a new pass through the same deck */\n restart = (event?: Event) => {\n event?.stopPropagation();\n this.setHot(true);\n this.deck?.focus({ preventScroll: true });\n this.arrive = 'fwd';\n this.inner = null;\n this.slide = 0;\n this.pass += 1;\n };\n\n back = (event?: Event) => {\n this.go(this.slide - 1, event);\n };\n\n segClass = (n: number) => {\n if (n === this.slide) {\n return 'pres-seg is-on';\n }\n if (n < this.slide) {\n return 'pres-seg is-seen';\n }\n return 'pres-seg';\n };\n\n segLabel = (n: number) => `Go to slide ${String(n + 1).padStart(2, '0')}`;\n\n <template>\n <div class='ex'>\n <div\n class='pres'\n tabindex='0'\n role='region'\n aria-label='Presentation. Space advances a build. Less-than and greater-than change slides.'\n data-test-pres\n {{this.boot}}\n {{on 'keydown' this.keys}}\n {{on 'pointerdown' this.grab}}\n {{on 'click' this.grab}}\n {{on 'focusin' this.onFocus}}\n {{on 'focusout' this.onBlur}}\n >\n {{! the deck is the demo's SUBSTANCE: the crossing matches this box,\n not the stage frame around it }}\n <div class='pres-stage' data-choreo-substance>\n <Presence\n @items={{this.pages}}\n @key={{pageKey}}\n @mode='sync'\n @initial={{false}}\n @custom={{this.arrive}}\n as |page h|\n >\n <div\n class={{if h.isPresent 'pres-leaf' 'pres-leaf is-leaving'}}\n {{motion\n presence=h\n initial=this.xfadeIn\n animate=this.xfadeOn\n exit=this.xfadeOut\n transition=(tuneMotion 'presentation' this.xfadeT 'xfadeT')\n }}\n >\n <Presence @items={{HOLD}} @key={{holdKey}} as |_scene hh|>\n <div class='pres-hold' {{motion presence=hh}}>\n <Choreo\n class={{this.slideClass page}}\n data-arrive={{this.arrive}}\n data-slide={{page.id}}\n data-test-pres-slide\n aria-label='Advance the presentation'\n {{on 'click' this.tap}}\n as |c|\n >\n <span hidden {{this.wire c}}></span>\n\n {{#if this.live}}\n {{#if (this.isPage page 0)}}\n <p\n class='pres-eye is-build'\n {{motion id='s0-eye' role='build'}}\n >\n Meridian — 001\n </p>\n {{! No figure. The title slide is type, scale and\n colour and nothing else: a solid word, an\n outlined word under it, and the second one does\n not arrive until you ask for it. }}\n <h2\n class='pres-title is-build'\n {{motion id='s0-title' role='build'}}\n data-test-pres-title\n >Signal</h2>\n <h2\n class='pres-title is-out is-build'\n {{motion id='s0-title-b' role='build'}}\n data-test-pres-title-b\n >Found</h2>\n <i\n class='pres-swipe is-build'\n {{motion id='s0-rule' role='build'}}\n ></i>\n <p\n class='pres-kicker is-build'\n {{motion id='s0-kicker' role='build'}}\n data-test-pres-kicker\n >Everything arrives somewhere.</p>\n <p\n class='pres-stamp is-build'\n {{motion id='s0-stamp' role='build'}}\n data-test-pres-stamp\n >In band</p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Parallel>\n <c.Tween\n @of={{c.id 's0-eye'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's0-title'}}\n @opacity={{array 0 1}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 1 duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n <c.Gate @delay={{0.7}} />\n <c.Tween\n @of={{c.id 's0-kicker'}}\n @opacity={{array 0 1}}\n @y={{array 8 0}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Gate />\n {{! the word you clicked for: FOUND rises into the\n outline, and the rule wipes out under it a\n beat later — one press, two moves }}\n <c.Parallel>\n <c.Tween\n @of={{c.id 's0-title-b'}}\n @opacity={{array 0 1}}\n @y={{array 34 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.66\n 'Step 2 duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Tween\n @of={{c.id 's0-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{0.2}}\n @duration={{tuneSeconds\n 'presentation'\n 0.62\n 'Step 3 duration'\n }}\n @ease={{GLIDE}}\n />\n </c.Parallel>\n <c.Gate />\n <c.Tween\n @of={{c.id 's0-stamp'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.86 1.08 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n {{else if (this.isPage page 1)}}\n <p\n class='pres-num is-build'\n {{motion id='s1-num' role='build'}}\n >\n 02\n </p>\n <div\n class='pres-plate is-build'\n {{motion id='s1-plate' role='build'}}\n ></div>\n <h2\n class='pres-hed is-build'\n {{motion id='s1-hed' role='build'}}\n >\n Latency is\n <em>a design choice.</em>\n </h2>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Parallel>\n <c.Tween\n @of={{c.id 's1-num'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 4 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's1-plate'}}\n @opacity={{array 0 1}}\n @scale={{array 0.92 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.56\n 'Step 5 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's1-hed'}}\n @opacity={{array 0 1}}\n @y={{array 14 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 6 duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n </c.Sequence>\n {{/if}}\n\n {{#if this.building}}\n <InnerScene as |n|>\n <span hidden {{this.wireInner n}}></span>\n <div class='pres-verse' data-test-pres-verse>\n {{#each LINES as |line|}}\n <section\n class='pres-cell'\n {{motion id=line.id role='cell'}}\n >\n <b\n class='pres-tick'\n {{motion id=line.tick role='tick'}}\n >{{line.n}}</b>\n <p\n {{motion id=line.copy role='copy'}}\n >{{line.text}}</p>\n <i\n class='pres-rule'\n {{motion id=line.rule role='rule'}}\n ></i>\n </section>\n {{/each}}\n </div>\n <n.Sequence>\n <n.Parallel>\n <n.Tween\n @of={{n.id 'cell-a'}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 7 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-a'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-a'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 8 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-a'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 9 duration'\n }}\n @ease='easeOut'\n />\n </n.Parallel>\n <n.Gate />\n <n.Parallel>\n <n.Tween\n @of={{n.id 'cell-b'}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 10 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-b'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-b'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 11 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-b'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 12 duration'\n }}\n @ease='easeOut'\n />\n {{! the third line rides in on the same gate,\n one beat behind the second — two verses on\n one click rather than one verse per click }}\n <n.Tween\n @of={{n.id 'cell-c'}}\n @y={{array 18 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 13 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-c'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-c'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 14 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-c'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 15 duration'\n }}\n @ease='easeOut'\n />\n </n.Parallel>\n </n.Sequence>\n </InnerScene>\n {{else}}\n <div class='pres-verse'>\n {{#each LINES as |line|}}\n <section class='pres-cell'>\n <b class='pres-tick'>{{line.n}}</b>\n <p>{{line.text}}</p>\n <i class='pres-rule is-on'></i>\n </section>\n {{/each}}\n </div>\n {{/if}}\n {{else if (this.isPage page 2)}}\n <p class='pres-idx'>03 — the route</p>\n <h2\n class='pres-hed is-tight is-build'\n {{motion id='s2-hed' role='build'}}\n >\n Five regions.\n <em>One hop.</em>\n </h2>\n <div class='pres-plot'>\n <Mesh @lineId='s2-line' />\n <span\n class='pres-origin is-poster'\n {{beacon 's2-shore'}}\n ></span>\n <span\n class='pres-hull is-poster is-build'\n {{motion id='s2-hull' role='bead'}}\n ></span>\n <ol class='pres-stations'>\n {{#each DAYS as |day|}}\n <li\n class='is-build'\n {{motion id=day.id role='day'}}\n >{{day.name}}</li>\n {{/each}}\n </ol>\n </div>\n <p\n class='pres-cap is-build'\n {{motion id='s2-cap' role='build'}}\n >\n Frankfurt holds.\n </p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Tween\n @of={{c.id 's2-hed'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Gate />\n <c.Parallel>\n <c.Tween\n @of={{c.id 's2-line'}}\n @pathLength={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n FLIGHT\n 'FLIGHT duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Move\n @of={{c.id 's2-hull'}}\n @from={{c.beacon 's2-shore'}}\n @path={{HOP_RIDE}}\n @size={{false}}\n @duration={{tuneSeconds\n 'presentation'\n FLIGHT\n 'FLIGHT duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Tween\n @of={{c.id 's2-hull'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.2\n 'Step 16 duration'\n }}\n />\n {{! The week on ONE gate. @stagger walks its\n ladder across every sprite the role matched,\n so five days used to be five clicks and are\n now one — and the ladder is timed to land\n its last rung exactly as the stroke above\n finishes drawing. }}\n <c.Tween\n @of={{c.role 'day'}}\n @opacity={{array 0 1}}\n @y={{array 6 0}}\n @stagger={{0.13}}\n @delay={{0.34}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's2-cap'}}\n @opacity={{array 0 1}}\n @delay={{0.96}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n />\n </c.Parallel>\n </c.Sequence>\n {{/if}}\n {{else if (this.isPage page 3)}}\n <p\n class='pres-idx is-build'\n {{motion id='s3-idx' role='build'}}\n >\n 04 — the stack\n </p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Tween\n @of={{c.id 's3-idx'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n\n {{#if this.building}}\n <InnerScene as |n|>\n <span hidden {{this.wireInner n}}></span>\n <ul class='pres-sys' data-test-pres-sys>\n {{#each MOVES as |move|}}\n <li\n class='pres-sys-item'\n {{motion id=move.id role='card'}}\n >\n <b\n {{motion id=move.num role='num'}}\n >{{move.n}}</b>\n <h3\n {{motion id=move.title role='title'}}\n >{{move.name}}</h3>\n <i\n class='pres-rule'\n {{motion id=move.rule role='rule'}}\n ></i>\n <p\n {{motion id=move.copy role='copy'}}\n >{{move.text}}</p>\n </li>\n {{/each}}\n </ul>\n <n.Sequence>\n <n.Parallel>\n <n.Hold @of={{n.id 'm1'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm1'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 17 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 18 duration'\n }}\n />\n </n.Parallel>\n <n.Gate />\n <n.Parallel>\n <n.Hold @of={{n.id 'm2'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm2'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 19 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 20 duration'\n }}\n />\n {{! the third move chases the second on the\n SAME gate — the system reads as a set\n completing, not as two more clicks }}\n <n.Hold @of={{n.id 'm3'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm3'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 21 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 22 duration'\n }}\n />\n </n.Parallel>\n </n.Sequence>\n </InnerScene>\n {{else}}\n <ul class='pres-sys'>\n {{#each MOVES as |move|}}\n <li class='pres-sys-item'>\n <b>{{move.n}}</b>\n <h3>{{move.name}}</h3>\n <i class='pres-rule is-on'></i>\n <p>{{move.text}}</p>\n </li>\n {{/each}}\n </ul>\n {{/if}}\n {{else}}\n <p class='pres-idx'>05 — the close</p>\n <h2\n class='pres-close is-build'\n {{motion id='s4-hed' role='build'}}\n >The signal\n <em>holds.</em></h2>\n <p\n class='pres-line is-build'\n {{motion id='s4-l1' role='build'}}\n >\n Meridian · Signal Found · 001\n </p>\n <p\n class='pres-seal is-build'\n {{motion id='s4-seal' role='build'}}\n >Sealed</p>\n\n {{#if this.building}}\n <c.Sequence>\n {{! the close states itself and signs itself on\n one press: the line follows the head without\n being asked, and only the seal is gated }}\n <c.Parallel>\n <c.Tween\n @of={{c.id 's4-hed'}}\n @opacity={{array 0 1}}\n @y={{array 12 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 23 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's4-l1'}}\n @opacity={{array 0 1}}\n @delay={{0.42}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n <c.Gate />\n <c.Tween\n @of={{c.id 's4-seal'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.86 1.08 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n {{/if}}\n {{/if}}\n </Choreo>\n </div>\n </Presence>\n </div>\n </Presence>\n </div>\n\n <div class='pres-bar'>\n <nav class='pres-segs' data-slide={{this.slide}} aria-label='Slides'>\n {{#each SLIDES as |n|}}\n <button\n type='button'\n class={{this.segClass n}}\n aria-label={{this.segLabel n}}\n aria-current={{if (this.is n) 'true'}}\n data-test-pres-seg={{n}}\n tabindex='-1'\n {{on 'click' (fn this.go n)}}\n ></button>\n {{/each}}\n </nav>\n <div class='pres-foot'>\n <div class='pres-meta'>\n <p class='pres-keys' aria-hidden='true'>\n <span><kbd>Space</kbd> build</span>\n <span><kbd>‹</kbd><kbd>›</kbd> step</span>\n </p>\n <span class='pres-folio'>{{this.folio}}</span>\n </div>\n <label class='pres-xfade'>\n <span class='pres-xfade-tag'>Transition</span>\n <select\n data-test-pres-xfade\n tabindex='-1'\n {{on 'click' this.stop}}\n {{on 'change' this.pickFade}}\n >\n {{#each XFADES as |kind|}}\n <option value={{kind}} selected={{this.isFade kind}}>\n {{kind}}\n </option>\n {{/each}}\n </select>\n </label>\n {{! ‹ and › walk every BUILD, not every slide: one press is one\n beat, and a slide boundary is just the beat where the page\n happens to turn. }}\n {{! Deliberately never disabled. Knowing whether a build remains\n means reading run.segment, and run state is not tracked — the\n @tracked write that would re-render this bar mid-build reads\n as a moved sprite and restarts the run (the same reason\n is-hot goes through classList). A press at either end is a\n harmless no-op, which is the cheaper honesty. }}\n <button\n type='button'\n class='pres-restart'\n aria-label='Start over'\n data-test-pres-restart\n tabindex='-1'\n {{on 'click' this.restart}}\n >Start over</button>\n <button\n type='button'\n class='pres-arr'\n aria-label='Previous build'\n data-test-pres-back\n tabindex='-1'\n {{on 'click' this.stepBack}}\n >‹</button>\n <button\n type='button'\n class='pres-arr'\n aria-label='Next build'\n data-test-pres-fwd\n tabindex='-1'\n {{on 'click' this.stepFwd}}\n >›</button>\n </div>\n </div>\n <i class='pres-ring' aria-hidden='true'></i>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* An engraved atlas plate, not a poster.\n *\n * This deck used to be the largest object on the site — 760px, against a\n * vocabulary that runs 300-440 everywhere else and 320 for Slides, which is\n * the same idea at a quarter of the area. At that width every clamp in this\n * section sat pinned to its own maximum, so the fluid middle term never ran\n * at the size anyone actually looks at the thing: a 224px ghost numeral over\n * a 10px mono label, 20:1, set as though it were a billboard.\n *\n * It is 560 x 315 now — 16:9, the shape a deck is actually cut to — and\n * because the ratio is FIXED the slide is a true scale model: every position,\n * measure and step is in `cqw` against the plate, so the whole composition\n * is one drawing that resolves at any size rather than a set of percentages\n * that drift apart from a set of pixel type sizes. The only clamps left are\n * legibility floors on the type, and they engage below ~440 only.\n *\n * The chrome does NOT scale with it: the imprint is UI, so it stays at the\n * site's fixed 10px mono, same as .replay and .wires-bar button.\n */\n .ex:has(.pres) {\n padding-block: 16px;\n }\n\n .pres {\n position: relative;\n display: flex;\n flex-direction: column;\n /* ONE rounded rect: a 16:9 stage with the transport welded under it, the\n way a deck actually presents itself. The bar is not chrome floating near\n the slide, it is the bottom 46px of the same object. */\n width: min(94cqw, calc((92cqh - 46px) * 1.7778), 560px);\n border: 1px solid var(--line-strong);\n border-radius: 16px;\n background: var(--bg-well);\n box-shadow: 0 12px 30px\n rgba(var(--shadow-rgb), calc(0.32 * var(--shadow-a)));\n container-type: inline-size;\n }\n\n /* The type scale — five steps on ~1.42, in cqw so they ride the plate.\n *\n * Declared on .pres-stage rather than on .pres itself: an element that\n * ESTABLISHES a container resolves its own cqw against its ANCESTOR\n * container, so a `cqw` written on .pres would silently measure the stage\n * around it instead of the plate. One level in, it measures the plate.\n *\n * The floors are legibility, not layout — below about 440px the type stops\n * shrinking with the plate so a phone-width tile stays readable. The caps sit\n * just above the 560 values, so they never bind at the sizes we ship. */\n .pres-stage {\n --pres-1: clamp(\n 8px,\n 1.8cqw,\n 11px\n ); /* mono labels, folio, ticks, caption */\n --pres-2: clamp(10.5px, 2.55cqw, 15px); /* body copy */\n --pres-3: clamp(14px, 3.6cqw, 21px); /* the system's card titles */\n --pres-4: clamp(18px, 5.15cqw, 30px); /* slide heads */\n --pres-5: clamp(24px, 7.3cqw, 42px); /* the deck title, and the close */\n /* the plate's margin, and the one measure every slide is set against */\n --pres-m: 7cqw;\n }\n\n /* Shown on :focus (not only :focus-visible) because a click is how you\n take the deck. A hairline drawn just INSIDE the plate edge — the ring\n used to be a 2.5px copper band at inset -8px with a second shadow ring\n outside it, which is a lot of chrome to say \"focused\". */\n .pres:focus,\n .pres:focus-visible {\n outline: none;\n }\n\n .pres-ring {\n position: absolute;\n /* the deck is one object, so the focus mark is one hairline around all of\n it — drawn INSIDE the edge, where it cannot be clipped by the stage */\n inset: 0;\n border: 1.5px solid var(--copper-ink);\n border-radius: 16px;\n pointer-events: none;\n z-index: 8;\n opacity: 0;\n transition: opacity 140ms var(--ease);\n }\n\n .pres.is-hot > .pres-ring,\n .pres:focus > .pres-ring,\n .pres:focus-visible > .pres-ring {\n opacity: 1;\n }\n\n /* the plate: one surface, all four corners, a shadow that lifts it off the\n stage the way .slide-plate and the Camera prints do */\n .pres-stage {\n position: relative;\n aspect-ratio: 16 / 9;\n overflow: hidden;\n /* top corners only: the bottom two belong to the bar below it */\n border-radius: 15px 15px 0 0;\n border-bottom: 1px solid var(--line-strong);\n background: var(--bg-well);\n }\n\n /* ---- the four grounds ----\n *\n * Atlas, Ember, Flux and Halo — the brand's four gradients, plus paper. A\n * deck that paints every slide the same colour is a document; the change of\n * ground is the punctuation, so the deck runs Atlas, paper, a split, Flux,\n * Ember, and Halo does the accent work throughout.\n *\n * Each ground sets four tokens — --sl-ink, --sl-dim, --sl-faint, --sl-accent\n * — and every element on the slide reads those instead of naming a hue, so\n * one line here re-colours a whole slide and no rule below has to know which\n * ground it is standing on.\n *\n * No ground bottoms out in black. Flux is the dark one and it lands on a\n * steel #262b2e, because black type on a black field is not contrast, it is\n * just two absences. */\n .pres-slide {\n position: relative;\n height: 100%;\n overflow: hidden;\n cursor: pointer;\n touch-action: pan-y;\n color: var(--sl-ink);\n --sl-ink: var(--ink);\n --sl-dim: var(--ink-dim);\n --sl-faint: var(--ink-faint);\n --sl-accent: #edce9e;\n --sl-rule: rgba(var(--ink-rgb), 0.16);\n background: var(--bg-spot);\n }\n\n /* ATLAS — burnt sienna into a deep red-brown. The deck opens on it. */\n .pres-slide.is-atlas {\n --sl-ink: #fdf2ea;\n --sl-dim: #e8c3ac;\n --sl-faint: #c1917a;\n --sl-accent: #edce9e;\n --sl-rule: rgba(253, 242, 234, 0.26);\n background: linear-gradient(\n 152deg,\n #c2482b 0%,\n #7e2413 46%,\n #3a1008 100%\n );\n }\n\n /* EMBER — orange into red. The close. */\n .pres-slide.is-ember {\n --sl-ink: #fff6f0;\n --sl-dim: #ffd9c4;\n --sl-faint: #f0ad91;\n --sl-accent: #3a1008;\n --sl-rule: rgba(255, 246, 240, 0.32);\n background: linear-gradient(\n 152deg,\n #f2843e 0%,\n #e8502a 46%,\n #d6301c 100%\n );\n }\n\n /* FLUX — steel. The stack sits here, and this is the deck's dark slide:\n a grey that still has light in it, never a black hole. */\n .pres-slide.is-flux {\n --sl-ink: #f4f6f7;\n --sl-dim: #c3cace;\n --sl-faint: #939ba1;\n --sl-accent: #edce9e;\n --sl-rule: rgba(244, 246, 247, 0.2);\n background: linear-gradient(\n 152deg,\n #9aa2a8 0%,\n #5a6268 46%,\n #262b2e 100%\n );\n }\n\n /* PAPER — the one genuinely light slide. A deck is printed, and print has a\n white page in it somewhere. */\n .pres-slide.is-paper {\n --sl-ink: #1b1712;\n --sl-dim: #6b6255;\n --sl-faint: #9a9082;\n --sl-accent: #d6301c;\n --sl-rule: rgba(27, 23, 18, 0.18);\n background:\n radial-gradient(80% 90% at 92% 8%, #d6301c0d 0%, transparent 58%),\n #f7f3ec;\n }\n\n /* SPLIT — paper on the left, Flux on the right, cut at 62%. The figure\n straddles the cut, which is the whole point of the slide. */\n .pres-slide.is-split {\n --sl-ink: #1b1712;\n --sl-dim: #6b6255;\n --sl-faint: #9a9082;\n --sl-accent: #d6301c;\n --sl-rule: rgba(27, 23, 18, 0.18);\n background: linear-gradient(\n 100deg,\n #f7f3ec 0 62%,\n #4a5157 62%,\n #262b2e 100%\n );\n }\n\n .pres-leaf {\n position: absolute;\n inset: 0;\n z-index: 1;\n }\n\n .pres-leaf.is-leaving {\n z-index: 0;\n pointer-events: none;\n }\n\n .pres-hold {\n position: absolute;\n inset: 0;\n }\n\n .pres-inner .pres-verse,\n .pres-inner .pres-sys {\n pointer-events: auto;\n }\n\n /* ---- the imprint ----\n *\n * Not a bar. The footer used to be welded to the plate — its own fill, a\n * border-top, two mitred corners — carrying six species of control, where\n * no other stage on the site carries more than two and none welds anything.\n * What is left is margin: a rule, then one line of mono.\n *\n * This is UI, not slide, so it does NOT ride the plate's cqw scale: 10px\n * mono at 0.12em, the same chrome as .replay and .wires-bar button. */\n .pres-bar {\n display: flex;\n flex-direction: column;\n flex-shrink: 0;\n height: 46px;\n padding: 0 10px 8px;\n border-radius: 0 0 15px 15px;\n }\n\n /* the rail sits hard against the stage's bottom edge — a progress rule for\n the slide above it, not a control floating in the bar */\n .pres-segs {\n display: flex;\n gap: 2px;\n margin: 0 -10px;\n }\n\n /* The rule is a hairline; the BUTTON around it is not. A 2px-tall control is\n a 2px-tall pointer target, and on a finger it may as well not be there —\n so the hit area is a proper 16px band and the mark inside it stays a line. */\n .pres-seg {\n display: block;\n position: relative;\n height: 16px;\n flex: 1;\n margin: 0;\n padding: 0;\n border: 0;\n background: transparent;\n cursor: pointer;\n }\n\n .pres-seg::before {\n content: '';\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 2px;\n margin-top: -1px;\n border-radius: 1px;\n background: color-mix(in srgb, var(--ink) 14%, transparent);\n transition: background 140ms var(--ease);\n }\n\n .pres-seg.is-seen::before {\n background: color-mix(in srgb, var(--copper) 58%, transparent);\n }\n\n .pres-seg.is-on::before {\n background: var(--ember-hot);\n }\n\n @media (hover: hover) {\n .pres-seg:hover::before {\n background: var(--ink-dim);\n }\n }\n\n .pres-foot {\n display: flex;\n flex: 1;\n align-items: center;\n gap: 8px;\n }\n\n /* Same grid cell so swapping folio ⇄ keys cannot resize the imprint — a\n reflow here moves every sprite and would restart a gated run. */\n .pres-meta {\n display: grid;\n flex: 1;\n min-width: 0;\n justify-items: start;\n align-items: center;\n }\n\n .pres-keys,\n .pres-folio {\n grid-area: 1 / 1;\n }\n\n .pres-keys {\n display: flex;\n visibility: hidden;\n align-items: center;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .pres.is-hot .pres-keys {\n visibility: visible;\n }\n\n .pres.is-hot .pres-folio {\n visibility: hidden;\n }\n\n .pres-keys span {\n display: flex;\n align-items: center;\n gap: 4px;\n }\n\n .pres-keys kbd {\n display: inline-block;\n padding: 1px 4px;\n border: 1px solid var(--line-strong);\n border-radius: 3px;\n font: inherit;\n letter-spacing: 0.08em;\n color: var(--copper-ink);\n }\n\n .pres-folio {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n /* the transition picker: a real <select>, so the four kinds are a list you\n read rather than a word you have to press four times to see */\n .pres-xfade {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n gap: 6px;\n margin: 0;\n cursor: pointer;\n }\n\n .pres-xfade-tag {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .pres-xfade select {\n appearance: none;\n padding: 4px 20px 4px 9px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background:\n /* the chevron, drawn rather than typed */\n linear-gradient(45deg, transparent 50%, currentcolor 50%)\n calc(100% - 11px) calc(50% - 1px) / 4px 4px no-repeat,\n linear-gradient(-45deg, currentcolor 50%, transparent 50%)\n calc(100% - 7px) calc(50% - 1px) / 4px 4px no-repeat;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n cursor: pointer;\n }\n\n @media (hover: hover) {\n .pres-xfade:hover select {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n\n /* Start over — the same pill as the arrows, wearing a word instead of a\n glyph. It reads as part of the transport rather than a fifth species. */\n .pres-restart {\n flex-shrink: 0;\n margin: 0;\n padding: 4px 9px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: transparent;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .pres-restart:hover {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n @container (max-width: 420px) {\n .pres-foot {\n flex-wrap: wrap;\n row-gap: 6px;\n }\n }\n\n .pres-arr {\n flex-shrink: 0;\n display: grid;\n place-items: center;\n width: 21px;\n height: 21px;\n padding: 0;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: transparent;\n color: var(--ink-dim);\n font-size: 10px;\n line-height: 1;\n }\n\n @media (hover: hover) {\n .pres-arr:hover:not(:disabled) {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n\n .pres-arr:disabled {\n opacity: 0.28;\n cursor: default;\n }\n\n /* builds start invisible; the timeline writes them. Back-arrive skips the\n timeline and shows the still — the previous slide, already built. */\n .pres-slide .is-build {\n opacity: 0;\n }\n\n .pres-slide[data-arrive='back'] .is-build {\n opacity: 1;\n transform: none;\n }\n\n .pres-eye,\n .pres-title,\n .pres-kicker,\n .pres-stamp,\n .pres-num,\n .pres-plate,\n .pres-hed,\n .pres-line,\n .pres-idx,\n .pres-plot,\n .pres-cap,\n .pres-sys,\n .pres-close,\n .pres-seal {\n position: absolute;\n }\n\n .pres-eye,\n .pres-idx {\n top: 5.5cqw;\n left: var(--pres-m);\n margin: 0;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n /* 01 — the title.\n *\n * No figure on this slide: the contrast IS the design. One word solid, the\n * word under it outlined in Halo, at a step nothing else in the deck gets\n * near — 13cqw against a 1.8cqw eyebrow is better than 7:1, and that gap is\n * what makes a title slide read as a title rather than as a big heading. */\n .pres-title {\n top: 14cqw;\n left: var(--pres-m);\n margin: 0;\n font-family: var(--font-display-alt);\n font-weight: 700;\n font-stretch: 62%;\n font-variation-settings: 'wdth' 62;\n font-size: clamp(30px, 13cqw, 76px);\n letter-spacing: -0.045em;\n line-height: 0.84;\n text-transform: uppercase;\n }\n\n /* the counter-word: the same cut, drawn rather than filled */\n .pres-title.is-out {\n top: 25cqw;\n color: transparent;\n -webkit-text-stroke: 1.4px var(--sl-accent);\n }\n\n /* the rule that wipes out under the pair, on the same press */\n .pres-swipe {\n position: absolute;\n top: 38.5cqw;\n left: var(--pres-m);\n display: block;\n width: 32cqw;\n height: 2px;\n background: var(--sl-accent);\n transform-origin: 0 50%;\n }\n\n .pres-slide.is-title .pres-kicker {\n top: 43cqw;\n }\n\n .pres-kicker {\n top: 26cqw;\n left: var(--pres-m);\n margin: 0;\n width: fit-content;\n font-size: var(--pres-2);\n font-style: italic;\n color: var(--sl-dim);\n letter-spacing: -0.01em;\n }\n\n /* the mesh is 320x110 — a band. Its box carries the same ratio so the drawing\n fills it rather than floating in the middle of it. */\n .pres-plot {\n top: 25cqw;\n left: var(--pres-m);\n width: 86cqw;\n height: 29.6cqw;\n }\n\n /* on the split ground the head sits in the paper half and the figure runs\n across the cut, so the type gets the left 58% and nothing overhangs */\n .pres-slide.is-split .pres-hed {\n right: 44cqw;\n }\n /* the station labels cross the cut too, so the ones that land on steel take\n their contrast from that half rather than from the paper half's --sl-faint */\n .pres-slide.is-split .pres-stations li:nth-child(n + 4) {\n color: rgba(244, 246, 247, 0.78);\n }\n\n /* the two ends of the ride. Positioned in % of the CHART, which is itself a\n cqw box — so the bead sits on the same point of the curve at every size. */\n .pres-origin,\n .pres-hull {\n position: absolute;\n width: 1.3cqw;\n height: 1.3cqw;\n min-width: 6px;\n min-height: 6px;\n }\n\n .pres-origin {\n left: 3.75%;\n top: 65%;\n margin-top: -0.65cqw;\n }\n\n .pres-hull {\n left: 96.25%;\n top: 57.5%;\n margin-top: -0.65cqw;\n border-radius: 999px;\n background: var(--sl-accent);\n box-shadow:\n 0 0 0 0.55cqw color-mix(in srgb, var(--sl-accent) 24%, transparent),\n 0 0 12px color-mix(in srgb, var(--sl-accent) 60%, transparent);\n }\n\n .pres-stamp,\n .pres-seal {\n margin: 0;\n width: fit-content;\n font-family: var(--font-mono);\n font-weight: 500;\n font-size: var(--pres-1);\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n .pres-stamp {\n right: var(--pres-m);\n bottom: 5cqw;\n }\n\n .pres-seal {\n right: var(--pres-m);\n bottom: 6cqw;\n padding: 1.4cqw 1.8cqw 1.2cqw;\n border: 1px solid var(--sl-accent);\n border-radius: 999px;\n }\n\n /* 02 — the idea: a cropped numeral, a copper plate, one sentence.\n The numeral is not on the type scale: it is a graphic at 10% ink, cropped\n by two edges, and it is sized against the plate rather than the ladder. */\n .pres-num {\n right: -1cqw;\n top: -4cqw;\n margin: 0;\n font-family: var(--font-display-alt);\n font-weight: 700;\n font-stretch: 62%;\n font-variation-settings: 'wdth' 62;\n font-size: 17cqw;\n line-height: 0.8;\n letter-spacing: -0.06em;\n color: color-mix(in srgb, var(--ink) 10%, transparent);\n pointer-events: none;\n }\n\n .pres-plate {\n top: 0;\n right: 0;\n bottom: 0;\n width: 26cqw;\n background: linear-gradient(\n 160deg,\n #ff7a45 0%,\n #c42712 42%,\n #2a0c08 100%\n );\n box-shadow: -8px 0 24px\n rgba(var(--shadow-rgb), calc(0.3 * var(--shadow-a)));\n }\n\n .pres-hed,\n .pres-close {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n letter-spacing: -0.04em;\n line-height: 0.94;\n }\n\n .pres-hed {\n top: 13cqw;\n left: var(--pres-m);\n right: 34cqw;\n font-size: var(--pres-4);\n }\n\n .pres-hed.is-tight {\n top: 11cqw;\n right: 9cqw;\n }\n\n /* Plex Sans italic sets optically larger than Syne 800 at the same body\n size, so the counter-voice steps down to sit level with the display line\n instead of over-reaching it. */\n .pres-hed em,\n .pres-close em {\n display: block;\n font-family: var(--font);\n font-weight: 400;\n font-style: italic;\n font-size: 0.88em;\n letter-spacing: -0.02em;\n color: var(--sl-dim);\n }\n\n .pres-line {\n left: var(--pres-m);\n margin: 0;\n font-size: var(--pres-2);\n color: var(--sl-ink);\n letter-spacing: -0.01em;\n }\n\n .pres-verse {\n position: absolute;\n top: 28cqw;\n left: var(--pres-m);\n right: 34cqw;\n bottom: 5cqw;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1.2cqw;\n margin: 0;\n }\n\n .pres-cell {\n position: relative;\n display: grid;\n grid-template-columns: auto 1fr;\n grid-template-rows: auto auto;\n column-gap: 1.6cqw;\n row-gap: 0.6cqw;\n min-height: 0;\n }\n\n .pres-tick {\n grid-row: 1 / -1;\n align-self: start;\n margin: 0;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n font-weight: 500;\n letter-spacing: 0.14em;\n color: var(--sl-accent);\n }\n\n .pres-cell p {\n margin: 0;\n font-size: var(--pres-2);\n letter-spacing: -0.01em;\n }\n\n .pres-rule {\n grid-column: 2;\n display: block;\n height: 1px;\n width: 100%;\n max-width: 11em;\n margin: 0;\n background: var(--sl-accent);\n transform-origin: 0 50%;\n opacity: 0;\n }\n\n .pres-rule.is-on {\n opacity: 1;\n }\n\n .pres-inner .pres-tick,\n .pres-inner .pres-cell p,\n .pres-inner .pres-rule,\n .pres-inner .pres-sys-item,\n .pres-inner .pres-sys-item b,\n .pres-inner .pres-sys-item h3,\n .pres-inner .pres-sys-item p {\n opacity: 0;\n }\n\n .pres-stations {\n position: absolute;\n left: 2%;\n right: 2%;\n bottom: -14%;\n display: flex;\n justify-content: space-between;\n margin: 0;\n padding: 0;\n list-style: none;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-faint);\n }\n\n .pres-cap {\n right: var(--pres-m);\n bottom: 5cqw;\n left: auto;\n margin: 0;\n width: fit-content;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n /* 04 — the system.\n A band, not the whole plate: the cards used to sit in a box running to the\n bottom margin, which left the lower third empty because every card's\n content is top-aligned inside it. */\n .pres-sys {\n top: 15cqw;\n left: var(--pres-m);\n right: var(--pres-m);\n bottom: 7cqw;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 4cqw;\n margin: 0;\n padding: 0;\n list-style: none;\n }\n\n .pres-sys-item {\n display: flex;\n flex-direction: column;\n gap: 0.7cqw;\n min-width: 0;\n padding-top: 1.6cqw;\n border-top: 1px solid var(--sl-rule);\n }\n\n .pres-sys-item b {\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n font-weight: 500;\n letter-spacing: 0.16em;\n color: var(--sl-accent);\n }\n\n .pres-sys-item h3 {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n font-size: var(--pres-3);\n letter-spacing: -0.035em;\n }\n\n /* number, title and rule hold the top of the band; the descriptions drop to\n a shared baseline at the bottom of it, the way a spec plate sets them */\n .pres-sys-item p {\n margin: auto 0 0;\n font-size: var(--pres-2);\n line-height: 1.35;\n color: var(--sl-dim);\n }\n\n /* 05 — the close */\n .pres-close {\n top: 14cqw;\n left: var(--pres-m);\n /* \"The system\" needs the full measure; a tighter inset breaks the line\n after \"The\", which is not a line break anyone chose */\n right: 16cqw;\n font-size: var(--pres-5);\n }\n\n .pres-slide[data-slide='4'] .pres-line {\n top: 38cqw;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-faint);\n }\n\n .choreo-site:not([data-theme='light']) .pres-stage {\n background: #2a2521;\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSeconds('presentation', IN, 'IN duration');\ntuneSeconds('presentation', 0.52, 'Step 1 duration');\ntuneSeconds('presentation', 0.66, 'Step 2 duration');\ntuneSeconds('presentation', 0.62, 'Step 3 duration');\ntuneSeconds('presentation', PULSE, 'PULSE duration');\ntuneSeconds('presentation', 0.48, 'Step 4 duration');\ntuneSeconds('presentation', 0.56, 'Step 5 duration');\ntuneSeconds('presentation', 0.48, 'Step 6 duration');\ntuneSeconds('presentation', 0.5, 'Step 7 duration');\ntuneSeconds('presentation', 0.4, 'Step 8 duration');\ntuneSeconds('presentation', 0.52, 'Step 9 duration');\ntuneSeconds('presentation', 0.5, 'Step 10 duration');\ntuneSeconds('presentation', 0.4, 'Step 11 duration');\ntuneSeconds('presentation', 0.52, 'Step 12 duration');\ntuneSeconds('presentation', 0.5, 'Step 13 duration');\ntuneSeconds('presentation', 0.4, 'Step 14 duration');\ntuneSeconds('presentation', 0.52, 'Step 15 duration');\ntuneSeconds('presentation', FLIGHT, 'FLIGHT duration');\ntuneSeconds('presentation', 0.2, 'Step 16 duration');\ntuneSeconds('presentation', GROUP, 'GROUP duration');\ntuneSeconds('presentation', 0.5, 'Step 17 duration');\ntuneSeconds('presentation', 0.36, 'Step 18 duration');\ntuneSeconds('presentation', 0.5, 'Step 19 duration');\ntuneSeconds('presentation', 0.36, 'Step 20 duration');\ntuneSeconds('presentation', 0.5, 'Step 21 duration');\ntuneSeconds('presentation', 0.36, 'Step 22 duration');\ntuneSeconds('presentation', 0.48, 'Step 23 duration');\n\nexport class PresentationDemo extends GalleryDemo {\n static stage = Presentation;\n static notes = PresentationNotes;\n}\n";
513
+ var presentation_default = "import type { ChoreoContext } from '@cardstack/choreo';\nimport { beacon, Choreo } from '@cardstack/choreo';\nimport type { TOC } from '@ember/component/template-only';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, Presence } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion, tuneSeconds } from '../lib/tuning';\nimport PresentationNotes from '../notes/presentation';\n\nconst IN = 0.32;\n/** the beat one element trails another INSIDE a single gate — the whole\n * reason the deck is ten clicks and not twenty: things that belong to the\n * same thought arrive together, a breath apart, on one press. */\nconst CHASE = 0.16;\nconst GROUP = 0.4;\nconst FLIGHT = 1.18;\nconst PULSE = 0.46;\nconst GLIDE = [0.22, 1, 0.36, 1] as const;\nconst LAST = 4;\nconst SLIDES = [0, 1, 2, 3, 4] as const;\nconst XFADE_T = { duration: 0.48, ease: [0.22, 1, 0.36, 1] } as const;\n\n/* ── the figure ──────────────────────────────────────────────────────────\n *\n * One drawing, built from real geometry rather than a hand-waved squiggle.\n * It has exactly one path the timeline draws (@pathLength) and one bead the\n * timeline flies (c.Move + @path), so the art and the score are the same\n * object seen twice. The title slide deliberately has no figure at all —\n * type, scale and colour carry it.\n */\n\n/** the route: five nodes, one polyline, drawn left to right */\nconst HOP =\n 'M 24 82 C 46 50, 62 32, 88 29 S 128 69, 152 75 S 194 31, 220 25 S 264 54, 292 64';\n/** the packet's flight along it, relative to the first node */\nconst HOP_RIDE =\n 'M 0 0 C 22 -32, 38 -50, 64 -53 S 104 -13, 128 -7 S 170 -51, 196 -57 S 240 -28, 268 -18';\n\n/** where the mesh's nodes sit, and the hue each one carries */\nconst NODES = [\n { cx: 24, cy: 82, key: 'n0', tone: 'is-halo' },\n { cx: 88, cy: 29, key: 'n1', tone: 'is-flux' },\n { cx: 152, cy: 75, key: 'n2', tone: 'is-halo' },\n { cx: 220, cy: 25, key: 'n3', tone: 'is-flux' },\n { cx: 292, cy: 64, key: 'n4', tone: 'is-ember' },\n] as const;\n\nconst DAYS = [\n { id: 's2-d0', name: 'SFO' },\n { id: 's2-d1', name: 'ORD' },\n { id: 's2-d2', name: 'DUB' },\n { id: 's2-d3', name: 'FRA' },\n { id: 's2-d4', name: 'SIN' },\n] as const;\n\nconst LINES = [\n {\n copy: 'c-a',\n id: 'cell-a',\n n: '01',\n rule: 'r-a',\n text: 'Nothing waits twice.',\n tick: 't-a',\n },\n {\n copy: 'c-b',\n id: 'cell-b',\n n: '02',\n rule: 'r-b',\n text: 'The edge is the middle.',\n tick: 't-b',\n },\n {\n copy: 'c-c',\n id: 'cell-c',\n n: '03',\n rule: 'r-c',\n text: 'Distance is a decision.',\n tick: 't-c',\n },\n] as const;\n\nconst MOVES = [\n {\n copy: 'm1-copy',\n id: 'm1',\n n: '01',\n name: 'Edge',\n num: 'm1-num',\n rule: 'm1-rule',\n text: 'Compute where it lands.',\n title: 'm1-title',\n },\n {\n copy: 'm2-copy',\n id: 'm2',\n n: '02',\n name: 'Mesh',\n num: 'm2-num',\n rule: 'm2-rule',\n text: 'Every node a front door.',\n title: 'm2-title',\n },\n {\n copy: 'm3-copy',\n id: 'm3',\n n: '03',\n name: 'Seal',\n num: 'm3-num',\n rule: 'm3-rule',\n text: 'Signed at the boundary.',\n title: 'm3-title',\n },\n] as const;\n\n/** the ground each slide is printed on, in order */\nconst GROUNDS = ['is-atlas', 'is-paper', 'is-split', 'is-flux', 'is-ember'];\n\ntype Xfade = 'fade' | 'push' | 'rise' | 'scale';\nconst XFADES: Xfade[] = ['fade', 'rise', 'scale', 'push'];\n\nconst pageKey = (page: { id: number; pass: number }) =>\n `${page.id}.${page.pass}`;\n\ntype SlideHost = HTMLElement & {\n choreoCtx?: ChoreoContext;\n innerCtx?: ChoreoContext;\n presentation?: Presentation;\n};\n/** inner Presence never leaves — so Choreo participants do not register as\n * the slide plate's exit children (they have no `exit` of their own). */\nconst HOLD = [{ id: 'hold' }];\nconst holdKey = (item: { id: string }) => item.id;\n\n/**\n * MESH — the route figure.\n *\n * Five nodes and the one hop that joins them. The faint chords are the paths\n * NOT taken; the bright polyline is the one the packet rides.\n */\nconst Mesh: TOC<{\n Args: { lineId: string };\n Element: SVGSVGElement;\n}> = <template>\n <svg class='pres-fig' viewBox='0 0 320 110' aria-hidden='true' ...attributes>\n <path\n class='pres-chord'\n d='M24 82 L152 75 M88 29 L220 25 M152 75 L292 64 M24 82 L88 29'\n />\n <path class='pres-ghost' d={{HOP}} />\n <path\n class='pres-stroke is-build'\n d={{HOP}}\n {{motion id=@lineId role='build'}}\n />\n {{#each NODES as |n|}}\n <circle class='pres-node {{n.tone}}' cx={{n.cx}} cy={{n.cy}} r='4.5' />\n <circle class='pres-node-ring' cx={{n.cx}} cy={{n.cy}} r='9' />\n {{/each}}\n </svg>\n <style scoped>\n /* builds start invisible; the timeline writes them. Back-arrive skips the\n timeline and shows the still — the previous slide, already built. */\n .pres-slide .is-build {\n opacity: 0;\n }\n\n .pres-slide[data-arrive='back'] .is-build {\n opacity: 1;\n transform: none;\n }\n\n /* The split is the one ground where a single ink cannot serve the slide: the\n figure runs across the cut, so anything derived from --sl-ink (dark, for\n the paper half) disappears into the steel half — black on black, the exact\n trap. These take a MID tone instead, which holds on cream and on steel. */\n .pres-slide.is-split .pres-ghost,\n .pres-slide.is-split .pres-chord,\n .pres-slide.is-split .pres-node-ring {\n stroke: rgba(140, 138, 134, 0.62);\n }\n\n /* ---- the figures ---- */\n\n .pres-fig {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n overflow: visible;\n }\n\n .pres-ghost,\n .pres-stroke {\n fill: none;\n stroke-width: 2.2;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n /* the path not yet drawn: the same line, at a whisper */\n .pres-ghost {\n stroke: color-mix(in srgb, var(--sl-ink) 16%, transparent);\n }\n\n /* the one line the timeline pulls on with @pathLength */\n .pres-stroke {\n stroke: var(--sl-accent);\n filter: drop-shadow(\n 0 0 6px color-mix(in srgb, var(--sl-accent) 45%, transparent)\n );\n }\n\n /* MESH — the chords are the routes NOT taken */\n .pres-chord {\n fill: none;\n stroke: color-mix(in srgb, var(--sl-ink) 10%, transparent);\n stroke-width: 1;\n stroke-dasharray: 3 4;\n }\n\n .pres-node-ring {\n fill: none;\n stroke: color-mix(in srgb, var(--sl-ink) 18%, transparent);\n stroke-width: 1;\n }\n\n /* the accent set — Halo, Flux and Ember as marks. The deck's one place for\n more than a single hue, and all three come from the brand's own ramps. */\n .pres-node.is-halo {\n fill: #edce9e;\n }\n\n .pres-node.is-flux {\n fill: #9aa2a8;\n }\n\n .pres-node.is-ember {\n fill: #f2843e;\n }\n </style>\n</template>;\n\n/**\n * Inner region: first render compiles no timeline, so the score waits one\n * frame for participants to exist, then plays. Outer never sees these ids.\n */\nclass InnerScene extends Component<{\n Blocks: { default: [ChoreoContext] };\n}> {\n @tracked go = false;\n boot = modifier(() => {\n const id = requestAnimationFrame(() => {\n this.go = true;\n });\n return () => cancelAnimationFrame(id);\n });\n\n <template>\n <Choreo class='pres-inner' {{this.boot}} as |c|>\n {{#if this.go}}\n {{yield c}}\n {{/if}}\n </Choreo>\n <style scoped>\n .pres-inner {\n position: absolute;\n inset: 0;\n pointer-events: none;\n }\n </style>\n </template>\n}\n\n/**\n * A presentation is a run that parks — and a deck is five of those, in a row.\n *\n * TEN CLICKS, end to end. Six of them open a gate; four turn the page. That\n * budget is the whole design: anything that belongs to one thought arrives on\n * one press, a breath apart (see CHASE), rather than dribbling out a beat per\n * click. The week on slide 03 used to be five clicks and is now one @stagger;\n * the third verse and the third move each chase the second instead of asking\n * again. Only the title slide gets two gates — it is the opening, it earns\n * the extra beat — and the deck accelerates from there to a single-gate close.\n *\n * Click the plate to open the next gate on this slide. Mash mid-build and the\n * segment completes rather than skipping. When the slide is done, the same\n * click turns the page. ‹ › in the imprint step one build back or forward,\n * turning the page past a slide's last build; the segments change slides\n * outright. Back shows the previous slide already built.\n *\n * Keys (once the deck itself is focused — the copper hairline): Space is a\n * click, < / > and the arrow keys step a build. Every control in the imprint\n * is a tab stop, and a key on one is that control's own.\n *\n * Slides crossfade through Presence (sync — leaver and newcomer together).\n * The fade kind is a control on the imprint. Slides 02 and 04 keep a nested\n * <Choreo> for the verse / the system: an inner region with its own score,\n * a preamble that plays itself, then a gate you click.\n */\nexport class Presentation extends Component {\n @tracked live = false;\n @tracked slide = 0;\n @tracked pass = 0;\n @tracked arrive: 'back' | 'fwd' = 'fwd';\n @tracked xfade: Xfade = 'fade';\n private seen = false;\n private c: ChoreoContext | null = null;\n private inner: ChoreoContext | null = null;\n private deck: HTMLElement | null = null;\n\n wire = modifier((el: HTMLElement, [c]: [ChoreoContext]) => {\n this.c = c;\n const host = el.closest('.pres-slide') as SlideHost | null;\n if (host) {\n host.presentation = this;\n host.choreoCtx = c;\n }\n return () => {\n if (this.c === c) {\n this.c = null;\n }\n if (host) {\n delete host.presentation;\n delete host.choreoCtx;\n }\n };\n });\n\n wireInner = modifier((el: HTMLElement, [c]: [ChoreoContext]) => {\n this.inner = c;\n const host = el.closest('.pres-slide') as SlideHost | null;\n if (host) {\n host.innerCtx = c;\n }\n return () => {\n if (this.inner === c) {\n this.inner = null;\n }\n if (host && host.innerCtx === c) {\n delete host.innerCtx;\n }\n };\n });\n\n /** the plate on top — during a crossfade the leaver still has a run */\n private plate(): SlideHost | null {\n return (this.deck?.querySelector(\n '.pres-leaf:not(.is-leaving) .pres-slide',\n ) ?? this.deck?.querySelector('.pres-slide')) as SlideHost | null;\n }\n\n boot = modifier((el: HTMLElement) => {\n this.deck = el;\n const id = requestAnimationFrame(() => {\n if (!this.seen) {\n this.seen = true;\n this.live = true;\n }\n });\n return () => {\n cancelAnimationFrame(id);\n this.deck = null;\n };\n });\n\n /** `pass` is in the key, not just the id: Start Over from slide 0 has to\n * remount the plate, and a key that only names the slide would call that\n * the same page and keep the built stills exactly as they were. */\n get pages() {\n return [{ id: this.slide, pass: this.pass }];\n }\n\n get atStart() {\n return this.slide <= 0;\n }\n\n get atEnd() {\n return this.slide >= LAST;\n }\n\n get folio() {\n return `${String(this.slide + 1).padStart(2, '0')} / 05`;\n }\n\n get building() {\n return this.arrive === 'fwd';\n }\n\n get xfadeIn() {\n switch (this.xfade) {\n case 'rise':\n return { opacity: 0, y: '68%' };\n case 'scale':\n return { opacity: 0, scale: 0.72 };\n case 'push':\n return { opacity: 1, x: this.arrive === 'fwd' ? '100%' : '-100%' };\n default:\n return { opacity: 0 };\n }\n }\n\n get xfadeOn() {\n return { opacity: 1, scale: 1, x: 0, y: 0 };\n }\n\n get xfadeOut() {\n switch (this.xfade) {\n case 'rise':\n return { opacity: 0, y: '-46%' };\n case 'scale':\n return { opacity: 0, scale: 1.24 };\n case 'push':\n return { opacity: 1, x: this.arrive === 'fwd' ? '-100%' : '100%' };\n default:\n return { opacity: 0 };\n }\n }\n\n get xfadeT() {\n return XFADE_T;\n }\n\n is = (n: number) => this.slide === n;\n isPage = (page: { id: number }, n: number) => page.id === n;\n\n /**\n * Five slides, four grounds. A deck that paints every slide the same colour\n * is a document; the change of ground IS the punctuation, so the deck runs\n * Atlas, paper, a split, Flux, Ember. Each ground carries its own ink and\n * its own accent, and every element on the slide reads them off the ground\n * rather than naming a hue itself.\n */\n slideClass = (page: { id: number }) => {\n const ground = GROUNDS[page.id] ?? 'is-atlas';\n return page.id === 0\n ? `pres-slide is-title ${ground}`\n : `pres-slide ${ground}`;\n };\n\n tap = (event?: Event) => {\n this.take(event);\n if (!this.live) {\n this.live = true;\n return;\n }\n const plate = this.plate();\n const inner = plate?.innerCtx?.run ?? this.inner?.run;\n if (inner && !inner.isDone()) {\n inner.advance();\n return;\n }\n const run = plate?.choreoCtx?.run ?? this.c?.run;\n if (run && !run.isDone()) {\n run.advance();\n return;\n }\n this.fwd();\n };\n\n /** `>` — the same verb the plate has: open the next gate, or turn the page */\n stepFwd = (event?: Event) => {\n event?.stopPropagation();\n this.tap(event);\n };\n\n /**\n * `<` — one build back, innermost first. A nested region is the last thing\n * that moved, so it is the first thing to un-move; only when neither run has\n * a segment behind it does this fall back to the previous slide.\n */\n stepBack = (event?: Event) => {\n event?.stopPropagation();\n this.take(event);\n const plate = this.plate();\n const inner = plate?.innerCtx?.run ?? this.inner?.run ?? null;\n // retreat() is the engine's Keynote rule: land parked at the previous\n // gate, everything ahead re-closed, and HOLD — no self-open, nothing\n // plays until the next advance replays the segment forward. The\n // binary-search scrub this replaces landed a millisecond shy of the\n // gate with the gate still open and the transport still willing: a\n // @delay gate re-opened itself, and a forward step skipped the\n // un-built segment instead of playing it.\n if (inner?.retreat()) {\n return;\n }\n const outer = plate?.choreoCtx?.run ?? this.c?.run ?? null;\n if (outer?.retreat()) {\n return;\n }\n this.back(event);\n };\n\n // The deck's keys are the deck's own: a control in the bar keeps its\n // Space and arrows, so a select still opens and a button still presses.\n // `{{on}}` types every handler in a realm as taking a plain `Event`.\n keys = (e: Event) => {\n const event = e as KeyboardEvent;\n if (event.repeat || event.target !== event.currentTarget) {\n return;\n }\n switch (event.key) {\n case ' ':\n event.preventDefault();\n this.tap();\n break;\n case '>':\n case '.':\n case 'ArrowRight':\n event.preventDefault();\n this.stepFwd(event);\n break;\n case '<':\n case ',':\n case 'ArrowLeft':\n event.preventDefault();\n this.stepBack(event);\n break;\n }\n };\n\n /** classList, not tracked: writing `hot` re-renders the region, and an\n * in-flight path looks like a moved sprite, which would restart the run. */\n private setHot(on: boolean) {\n this.deck?.classList.toggle('is-hot', on);\n }\n\n /** A press hands focus to the deck, where Space and the arrows work. A bar\n * control activated from the keyboard (a click with no pointer count)\n * keeps focus, so Tab carries on from where the visitor is. */\n private take(event?: Event) {\n if (event instanceof MouseEvent && event.detail === 0) {\n return;\n }\n this.setHot(true);\n this.deck?.focus({ preventScroll: true });\n }\n\n grab = (event: Event) => {\n this.setHot(true);\n (event.currentTarget as HTMLElement).focus({ preventScroll: true });\n };\n\n /** the key cue lights only while the deck itself holds focus: a bar\n * control keeps its keys, so focus on one leaves the cue dark */\n onFocus = (event: Event) => {\n this.setHot(event.target === event.currentTarget);\n };\n\n onBlur = (e: Event) => {\n const event = e as FocusEvent;\n const next = event.relatedTarget as Node | null;\n if (next && (event.currentTarget as HTMLElement).contains(next)) {\n return;\n }\n this.setHot(false);\n };\n\n /** the bar sits inside the deck, and the deck's own click takes focus —\n * a control in it has to keep its press to itself */\n stop = (event: Event) => {\n event.stopPropagation();\n };\n\n isFade = (kind: Xfade) => this.xfade === kind;\n\n pickFade = (event: Event) => {\n event.stopPropagation();\n this.xfade = (event.target as HTMLSelectElement).value as Xfade;\n };\n\n turn = (dir: 'back' | 'fwd') => {\n this.arrive = dir;\n this.inner = null;\n };\n\n go = (n: number, event?: Event) => {\n event?.stopPropagation();\n this.take(event);\n if (n === this.slide || n < 0 || n > LAST) {\n return;\n }\n this.turn(n < this.slide ? 'back' : 'fwd');\n this.slide = n;\n };\n\n fwd = (event?: Event) => {\n this.go(this.slide + 1, event);\n };\n\n /** back to slide 01, unbuilt — a new pass through the same deck */\n restart = (event?: Event) => {\n event?.stopPropagation();\n this.take(event);\n this.arrive = 'fwd';\n this.inner = null;\n this.slide = 0;\n this.pass += 1;\n };\n\n back = (event?: Event) => {\n this.go(this.slide - 1, event);\n };\n\n segClass = (n: number) => {\n if (n === this.slide) {\n return 'pres-seg is-on';\n }\n if (n < this.slide) {\n return 'pres-seg is-seen';\n }\n return 'pres-seg';\n };\n\n segLabel = (n: number) => `Go to slide ${String(n + 1).padStart(2, '0')}`;\n\n <template>\n <div class='ex'>\n <div\n class='pres'\n tabindex='0'\n role='region'\n aria-label='Presentation. Space advances a build; the arrow keys step a build back or forward.'\n data-test-pres\n {{this.boot}}\n {{on 'keydown' this.keys}}\n {{on 'pointerdown' this.grab}}\n {{on 'click' this.grab}}\n {{on 'focusin' this.onFocus}}\n {{on 'focusout' this.onBlur}}\n >\n {{! the deck is the demo's SUBSTANCE: the crossing matches this box,\n not the stage frame around it }}\n <div class='pres-stage' data-choreo-substance>\n <Presence\n @items={{this.pages}}\n @key={{pageKey}}\n @mode='sync'\n @initial={{false}}\n @custom={{this.arrive}}\n as |page h|\n >\n <div\n class={{if h.isPresent 'pres-leaf' 'pres-leaf is-leaving'}}\n {{motion\n presence=h\n initial=this.xfadeIn\n animate=this.xfadeOn\n exit=this.xfadeOut\n transition=(tuneMotion 'presentation' this.xfadeT 'xfadeT')\n }}\n >\n <Presence @items={{HOLD}} @key={{holdKey}} as |_scene hh|>\n <div class='pres-hold' {{motion presence=hh}}>\n <Choreo\n class={{this.slideClass page}}\n data-arrive={{this.arrive}}\n data-slide={{page.id}}\n data-test-pres-slide\n {{on 'click' this.tap}}\n as |c|\n >\n <span hidden {{this.wire c}}></span>\n\n {{#if this.live}}\n {{#if (this.isPage page 0)}}\n <p\n class='pres-eye is-build'\n {{motion id='s0-eye' role='build'}}\n >\n Meridian — 001\n </p>\n {{! No figure. The title slide is type, scale and\n colour and nothing else: a solid word, an\n outlined word under it, and the second one does\n not arrive until you ask for it. }}\n <h2\n class='pres-title is-build'\n {{motion id='s0-title' role='build'}}\n data-test-pres-title\n >Signal</h2>\n <h2\n class='pres-title is-out is-build'\n {{motion id='s0-title-b' role='build'}}\n data-test-pres-title-b\n >Found</h2>\n <i\n class='pres-swipe is-build'\n {{motion id='s0-rule' role='build'}}\n ></i>\n <p\n class='pres-kicker is-build'\n {{motion id='s0-kicker' role='build'}}\n data-test-pres-kicker\n >Everything arrives somewhere.</p>\n <p\n class='pres-stamp is-build'\n {{motion id='s0-stamp' role='build'}}\n data-test-pres-stamp\n >In band</p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Parallel>\n <c.Tween\n @of={{c.id 's0-eye'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's0-title'}}\n @opacity={{array 0 1}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 1 duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n <c.Gate @delay={{0.7}} />\n <c.Tween\n @of={{c.id 's0-kicker'}}\n @opacity={{array 0 1}}\n @y={{array 8 0}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Gate />\n {{! the word you clicked for: FOUND rises into the\n outline, and the rule wipes out under it a\n beat later — one press, two moves }}\n <c.Parallel>\n <c.Tween\n @of={{c.id 's0-title-b'}}\n @opacity={{array 0 1}}\n @y={{array 34 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.66\n 'Step 2 duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Tween\n @of={{c.id 's0-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{0.2}}\n @duration={{tuneSeconds\n 'presentation'\n 0.62\n 'Step 3 duration'\n }}\n @ease={{GLIDE}}\n />\n </c.Parallel>\n <c.Gate />\n <c.Tween\n @of={{c.id 's0-stamp'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.86 1.08 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n {{else if (this.isPage page 1)}}\n <p\n class='pres-num is-build'\n {{motion id='s1-num' role='build'}}\n >\n 02\n </p>\n <div\n class='pres-plate is-build'\n {{motion id='s1-plate' role='build'}}\n ></div>\n <h2\n class='pres-hed is-build'\n {{motion id='s1-hed' role='build'}}\n >\n Latency is\n <em>a design choice.</em>\n </h2>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Parallel>\n <c.Tween\n @of={{c.id 's1-num'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 4 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's1-plate'}}\n @opacity={{array 0 1}}\n @scale={{array 0.92 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.56\n 'Step 5 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's1-hed'}}\n @opacity={{array 0 1}}\n @y={{array 14 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 6 duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n </c.Sequence>\n {{/if}}\n\n {{#if this.building}}\n <InnerScene as |n|>\n <span hidden {{this.wireInner n}}></span>\n <div class='pres-verse' data-test-pres-verse>\n {{#each LINES as |line|}}\n <section\n class='pres-cell'\n {{motion id=line.id role='cell'}}\n >\n <b\n class='pres-tick'\n {{motion id=line.tick role='tick'}}\n >{{line.n}}</b>\n <p\n {{motion id=line.copy role='copy'}}\n >{{line.text}}</p>\n <i\n class='pres-rule'\n {{motion id=line.rule role='rule'}}\n ></i>\n </section>\n {{/each}}\n </div>\n <n.Sequence>\n <n.Parallel>\n <n.Tween\n @of={{n.id 'cell-a'}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 7 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-a'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-a'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 8 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-a'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 9 duration'\n }}\n @ease='easeOut'\n />\n </n.Parallel>\n <n.Gate />\n <n.Parallel>\n <n.Tween\n @of={{n.id 'cell-b'}}\n @y={{array 18 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 10 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-b'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-b'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 11 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-b'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 12 duration'\n }}\n @ease='easeOut'\n />\n {{! the third line rides in on the same gate,\n one beat behind the second — two verses on\n one click rather than one verse per click }}\n <n.Tween\n @of={{n.id 'cell-c'}}\n @y={{array 18 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 13 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 't-c'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.72 1.14 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'c-c'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.05}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.4\n 'Step 14 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'r-c'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.52\n 'Step 15 duration'\n }}\n @ease='easeOut'\n />\n </n.Parallel>\n </n.Sequence>\n </InnerScene>\n {{else}}\n <div class='pres-verse'>\n {{#each LINES as |line|}}\n <section class='pres-cell'>\n <b class='pres-tick'>{{line.n}}</b>\n <p>{{line.text}}</p>\n <i class='pres-rule is-on'></i>\n </section>\n {{/each}}\n </div>\n {{/if}}\n {{else if (this.isPage page 2)}}\n <p class='pres-idx'>03 — the route</p>\n <h2\n class='pres-hed is-tight is-build'\n {{motion id='s2-hed' role='build'}}\n >\n Five regions.\n <em>One hop.</em>\n </h2>\n <div class='pres-plot'>\n <Mesh @lineId='s2-line' />\n <span\n class='pres-origin is-poster'\n {{beacon 's2-shore'}}\n ></span>\n <span\n class='pres-hull is-poster is-build'\n {{motion id='s2-hull' role='bead'}}\n ></span>\n <ol class='pres-stations'>\n {{#each DAYS as |day|}}\n <li\n class='is-build'\n {{motion id=day.id role='day'}}\n >{{day.name}}</li>\n {{/each}}\n </ol>\n </div>\n <p\n class='pres-cap is-build'\n {{motion id='s2-cap' role='build'}}\n >\n Frankfurt holds.\n </p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Tween\n @of={{c.id 's2-hed'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Gate />\n <c.Parallel>\n <c.Tween\n @of={{c.id 's2-line'}}\n @pathLength={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n FLIGHT\n 'FLIGHT duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Move\n @of={{c.id 's2-hull'}}\n @from={{c.beacon 's2-shore'}}\n @path={{HOP_RIDE}}\n @size={{false}}\n @duration={{tuneSeconds\n 'presentation'\n FLIGHT\n 'FLIGHT duration'\n }}\n @ease={{GLIDE}}\n />\n <c.Tween\n @of={{c.id 's2-hull'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.2\n 'Step 16 duration'\n }}\n />\n {{! The week on ONE gate. @stagger walks its\n ladder across every sprite the role matched,\n so five days used to be five clicks and are\n now one — and the ladder is timed to land\n its last rung exactly as the stroke above\n finishes drawing. }}\n <c.Tween\n @of={{c.role 'day'}}\n @opacity={{array 0 1}}\n @y={{array 6 0}}\n @stagger={{0.13}}\n @delay={{0.34}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's2-cap'}}\n @opacity={{array 0 1}}\n @delay={{0.96}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n />\n </c.Parallel>\n </c.Sequence>\n {{/if}}\n {{else if (this.isPage page 3)}}\n <p\n class='pres-idx is-build'\n {{motion id='s3-idx' role='build'}}\n >\n 04 — the stack\n </p>\n\n {{#if this.building}}\n <c.Sequence>\n <c.Tween\n @of={{c.id 's3-idx'}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n\n {{#if this.building}}\n <InnerScene as |n|>\n <span hidden {{this.wireInner n}}></span>\n <ul class='pres-sys' data-test-pres-sys>\n {{#each MOVES as |move|}}\n <li\n class='pres-sys-item'\n {{motion id=move.id role='card'}}\n >\n <b\n {{motion id=move.num role='num'}}\n >{{move.n}}</b>\n <h3\n {{motion id=move.title role='title'}}\n >{{move.name}}</h3>\n <i\n class='pres-rule'\n {{motion id=move.rule role='rule'}}\n ></i>\n <p\n {{motion id=move.copy role='copy'}}\n >{{move.text}}</p>\n </li>\n {{/each}}\n </ul>\n <n.Sequence>\n <n.Parallel>\n <n.Hold @of={{n.id 'm1'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm1'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 17 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm1-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 18 duration'\n }}\n />\n </n.Parallel>\n <n.Gate />\n <n.Parallel>\n <n.Hold @of={{n.id 'm2'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm2'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 19 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm2-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 20 duration'\n }}\n />\n {{! the third move chases the second on the\n SAME gate — the system reads as a set\n completing, not as two more clicks }}\n <n.Hold @of={{n.id 'm3'}} @zIndex={{4}} />\n <n.Tween\n @of={{n.id 'm3'}}\n @opacity={{array 0 1}}\n @y={{array 22 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-num'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.8 1.12 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-title'}}\n @opacity={{array 0 1}}\n @y={{array 10 0}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n GROUP\n 'GROUP duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-rule'}}\n @scaleX={{array 0 1}}\n @opacity={{array 0 1}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.5\n 'Step 21 duration'\n }}\n @ease='easeOut'\n />\n <n.Tween\n @of={{n.id 'm3-copy'}}\n @opacity={{array 0 1}}\n @by='word'\n @stagger={{0.04}}\n @delay={{CHASE}}\n @duration={{tuneSeconds\n 'presentation'\n 0.36\n 'Step 22 duration'\n }}\n />\n </n.Parallel>\n </n.Sequence>\n </InnerScene>\n {{else}}\n <ul class='pres-sys'>\n {{#each MOVES as |move|}}\n <li class='pres-sys-item'>\n <b>{{move.n}}</b>\n <h3>{{move.name}}</h3>\n <i class='pres-rule is-on'></i>\n <p>{{move.text}}</p>\n </li>\n {{/each}}\n </ul>\n {{/if}}\n {{else}}\n <p class='pres-idx'>05 — the close</p>\n <h2\n class='pres-close is-build'\n {{motion id='s4-hed' role='build'}}\n >The signal\n <em>holds.</em></h2>\n <p\n class='pres-line is-build'\n {{motion id='s4-l1' role='build'}}\n >\n Meridian · Signal Found · 001\n </p>\n <p\n class='pres-seal is-build'\n {{motion id='s4-seal' role='build'}}\n >Sealed</p>\n\n {{#if this.building}}\n <c.Sequence>\n {{! the close states itself and signs itself on\n one press: the line follows the head without\n being asked, and only the seal is gated }}\n <c.Parallel>\n <c.Tween\n @of={{c.id 's4-hed'}}\n @opacity={{array 0 1}}\n @y={{array 12 0}}\n @duration={{tuneSeconds\n 'presentation'\n 0.48\n 'Step 23 duration'\n }}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.id 's4-l1'}}\n @opacity={{array 0 1}}\n @delay={{0.42}}\n @duration={{tuneSeconds\n 'presentation'\n IN\n 'IN duration'\n }}\n @ease='easeOut'\n />\n </c.Parallel>\n <c.Gate />\n <c.Tween\n @of={{c.id 's4-seal'}}\n @opacity={{array 0 1 1}}\n @scale={{array 0.86 1.08 1}}\n @duration={{tuneSeconds\n 'presentation'\n PULSE\n 'PULSE duration'\n }}\n @ease='easeOut'\n />\n </c.Sequence>\n {{/if}}\n {{/if}}\n {{/if}}\n </Choreo>\n </div>\n </Presence>\n </div>\n </Presence>\n </div>\n\n <div class='pres-bar'>\n <nav class='pres-segs' data-slide={{this.slide}} aria-label='Slides'>\n {{#each SLIDES as |n|}}\n <button\n type='button'\n class={{this.segClass n}}\n aria-label={{this.segLabel n}}\n aria-current={{if (this.is n) 'true'}}\n data-test-pres-seg={{n}}\n {{on 'click' (fn this.go n)}}\n ></button>\n {{/each}}\n </nav>\n <div class='pres-foot'>\n <div class='pres-meta'>\n <p class='pres-keys' aria-hidden='true'>\n <span><kbd>Space</kbd> build</span>\n <span><kbd>‹</kbd><kbd>›</kbd> step</span>\n </p>\n <span class='pres-folio'>{{this.folio}}</span>\n </div>\n <label class='pres-xfade'>\n <span class='pres-xfade-tag'>Transition</span>\n <select\n data-test-pres-xfade\n {{on 'click' this.stop}}\n {{on 'change' this.pickFade}}\n >\n {{#each XFADES as |kind|}}\n <option value={{kind}} selected={{this.isFade kind}}>\n {{kind}}\n </option>\n {{/each}}\n </select>\n </label>\n {{! ‹ and › walk every BUILD, not every slide: one press is one\n beat, and a slide boundary is just the beat where the page\n happens to turn. }}\n {{! Deliberately never disabled. Knowing whether a build remains\n means reading run.segment, and run state is not tracked — the\n @tracked write that would re-render this bar mid-build reads\n as a moved sprite and restarts the run (the same reason\n is-hot goes through classList). A press at either end is a\n harmless no-op, which is the cheaper honesty. }}\n <button\n type='button'\n class='pres-restart'\n aria-label='Start over'\n data-test-pres-restart\n {{on 'click' this.restart}}\n >Start over</button>\n <button\n type='button'\n class='pres-arr'\n aria-label='Previous build'\n data-test-pres-back\n {{on 'click' this.stepBack}}\n >‹</button>\n <button\n type='button'\n class='pres-arr'\n aria-label='Next build'\n data-test-pres-fwd\n {{on 'click' this.stepFwd}}\n >›</button>\n </div>\n </div>\n <i class='pres-ring' aria-hidden='true'></i>\n </div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* An engraved atlas plate, not a poster.\n *\n * This deck used to be the largest object on the site — 760px, against a\n * vocabulary that runs 300-440 everywhere else and 320 for Slides, which is\n * the same idea at a quarter of the area. At that width every clamp in this\n * section sat pinned to its own maximum, so the fluid middle term never ran\n * at the size anyone actually looks at the thing: a 224px ghost numeral over\n * a 10px mono label, 20:1, set as though it were a billboard.\n *\n * It is 560 x 315 now — 16:9, the shape a deck is actually cut to — and\n * because the ratio is FIXED the slide is a true scale model: every position,\n * measure and step is in `cqw` against the plate, so the whole composition\n * is one drawing that resolves at any size rather than a set of percentages\n * that drift apart from a set of pixel type sizes. The only clamps left are\n * legibility floors on the type, and they engage below ~440 only.\n *\n * The chrome does NOT scale with it: the imprint is UI, so it stays at the\n * site's fixed 10px mono, same as .replay and .wires-bar button.\n */\n .ex:has(.pres) {\n padding-block: 16px;\n }\n\n .pres {\n position: relative;\n display: flex;\n flex-direction: column;\n /* ONE rounded rect: a 16:9 stage with the transport welded under it, the\n way a deck actually presents itself. The bar is not chrome floating near\n the slide, it is the bottom 46px of the same object. */\n width: min(94cqw, calc((92cqh - 46px) * 1.7778), 560px);\n border: 1px solid var(--line-strong);\n border-radius: 16px;\n background: var(--bg-well);\n box-shadow: 0 12px 30px\n rgba(var(--shadow-rgb), calc(0.32 * var(--shadow-a)));\n container-type: inline-size;\n }\n\n /* The type scale — five steps on ~1.42, in cqw so they ride the plate.\n *\n * Declared on .pres-stage rather than on .pres itself: an element that\n * ESTABLISHES a container resolves its own cqw against its ANCESTOR\n * container, so a `cqw` written on .pres would silently measure the stage\n * around it instead of the plate. One level in, it measures the plate.\n *\n * The floors are legibility, not layout — below about 440px the type stops\n * shrinking with the plate so a phone-width tile stays readable. The caps sit\n * just above the 560 values, so they never bind at the sizes we ship. */\n .pres-stage {\n --pres-1: clamp(\n 8px,\n 1.8cqw,\n 11px\n ); /* mono labels, folio, ticks, caption */\n --pres-2: clamp(10.5px, 2.55cqw, 15px); /* body copy */\n --pres-3: clamp(14px, 3.6cqw, 21px); /* the system's card titles */\n --pres-4: clamp(18px, 5.15cqw, 30px); /* slide heads */\n --pres-5: clamp(24px, 7.3cqw, 42px); /* the deck title, and the close */\n /* the plate's margin, and the one measure every slide is set against */\n --pres-m: 7cqw;\n }\n\n /* Shown on :focus (not only :focus-visible) because a click is how you\n take the deck. A hairline drawn just INSIDE the plate edge — the ring\n used to be a 2.5px copper band at inset -8px with a second shadow ring\n outside it, which is a lot of chrome to say \"focused\". */\n .pres:focus,\n .pres:focus-visible {\n outline: none;\n }\n\n .pres-ring {\n position: absolute;\n /* the deck is one object, so the focus mark is one hairline around all of\n it — drawn INSIDE the edge, where it cannot be clipped by the stage */\n inset: 0;\n border: 1.5px solid var(--copper-ink);\n border-radius: 16px;\n pointer-events: none;\n z-index: 8;\n opacity: 0;\n transition: opacity 140ms var(--ease);\n }\n\n .pres.is-hot > .pres-ring,\n .pres:focus > .pres-ring,\n .pres:focus-visible > .pres-ring {\n opacity: 1;\n }\n\n /* the plate: one surface, all four corners, a shadow that lifts it off the\n stage the way .slide-plate and the Camera prints do */\n .pres-stage {\n position: relative;\n aspect-ratio: 16 / 9;\n overflow: hidden;\n /* top corners only: the bottom two belong to the bar below it */\n border-radius: 15px 15px 0 0;\n border-bottom: 1px solid var(--line-strong);\n background: var(--bg-well);\n }\n\n /* ---- the four grounds ----\n *\n * Atlas, Ember, Flux and Halo — the brand's four gradients, plus paper. A\n * deck that paints every slide the same colour is a document; the change of\n * ground is the punctuation, so the deck runs Atlas, paper, a split, Flux,\n * Ember, and Halo does the accent work throughout.\n *\n * Each ground sets four tokens — --sl-ink, --sl-dim, --sl-faint, --sl-accent\n * — and every element on the slide reads those instead of naming a hue, so\n * one line here re-colours a whole slide and no rule below has to know which\n * ground it is standing on.\n *\n * No ground bottoms out in black. Flux is the dark one and it lands on a\n * steel #262b2e, because black type on a black field is not contrast, it is\n * just two absences. */\n .pres-slide {\n position: relative;\n height: 100%;\n overflow: hidden;\n cursor: pointer;\n touch-action: pan-y;\n color: var(--sl-ink);\n --sl-ink: var(--ink);\n --sl-dim: var(--ink-dim);\n --sl-faint: var(--ink-faint);\n --sl-accent: #edce9e;\n --sl-rule: rgba(var(--ink-rgb), 0.16);\n background: var(--bg-spot);\n }\n\n /* ATLAS — burnt sienna into a deep red-brown. The deck opens on it. */\n .pres-slide.is-atlas {\n --sl-ink: #fdf2ea;\n --sl-dim: #e8c3ac;\n --sl-faint: #c1917a;\n --sl-accent: #edce9e;\n --sl-rule: rgba(253, 242, 234, 0.26);\n background: linear-gradient(\n 152deg,\n #c2482b 0%,\n #7e2413 46%,\n #3a1008 100%\n );\n }\n\n /* EMBER — orange into red. The close. */\n .pres-slide.is-ember {\n --sl-ink: #fff6f0;\n --sl-dim: #ffd9c4;\n --sl-faint: #f0ad91;\n --sl-accent: #3a1008;\n --sl-rule: rgba(255, 246, 240, 0.32);\n background: linear-gradient(\n 152deg,\n #f2843e 0%,\n #e8502a 46%,\n #d6301c 100%\n );\n }\n\n /* FLUX — steel. The stack sits here, and this is the deck's dark slide:\n a grey that still has light in it, never a black hole. */\n .pres-slide.is-flux {\n --sl-ink: #f4f6f7;\n --sl-dim: #c3cace;\n --sl-faint: #939ba1;\n --sl-accent: #edce9e;\n --sl-rule: rgba(244, 246, 247, 0.2);\n background: linear-gradient(\n 152deg,\n #9aa2a8 0%,\n #5a6268 46%,\n #262b2e 100%\n );\n }\n\n /* PAPER — the one genuinely light slide. A deck is printed, and print has a\n white page in it somewhere. */\n .pres-slide.is-paper {\n --sl-ink: #1b1712;\n --sl-dim: #6b6255;\n --sl-faint: #9a9082;\n --sl-accent: #d6301c;\n --sl-rule: rgba(27, 23, 18, 0.18);\n background:\n radial-gradient(80% 90% at 92% 8%, #d6301c0d 0%, transparent 58%),\n #f7f3ec;\n }\n\n /* SPLIT — paper on the left, Flux on the right, cut at 62%. The figure\n straddles the cut, which is the whole point of the slide. */\n .pres-slide.is-split {\n --sl-ink: #1b1712;\n --sl-dim: #6b6255;\n --sl-faint: #9a9082;\n --sl-accent: #d6301c;\n --sl-rule: rgba(27, 23, 18, 0.18);\n background: linear-gradient(\n 100deg,\n #f7f3ec 0 62%,\n #4a5157 62%,\n #262b2e 100%\n );\n }\n\n .pres-leaf {\n position: absolute;\n inset: 0;\n z-index: 1;\n }\n\n .pres-leaf.is-leaving {\n z-index: 0;\n pointer-events: none;\n }\n\n .pres-hold {\n position: absolute;\n inset: 0;\n }\n\n .pres-inner .pres-verse,\n .pres-inner .pres-sys {\n pointer-events: auto;\n }\n\n /* ---- the imprint ----\n *\n * Not a bar. The footer used to be welded to the plate — its own fill, a\n * border-top, two mitred corners — carrying six species of control, where\n * no other stage on the site carries more than two and none welds anything.\n * What is left is margin: a rule, then one line of mono.\n *\n * This is UI, not slide, so it does NOT ride the plate's cqw scale: 10px\n * mono at 0.12em, the same chrome as .replay and .wires-bar button. */\n .pres-bar {\n display: flex;\n flex-direction: column;\n flex-shrink: 0;\n height: 46px;\n padding: 0 10px 8px;\n border-radius: 0 0 15px 15px;\n }\n\n /* the rail sits hard against the stage's bottom edge — a progress rule for\n the slide above it, not a control floating in the bar */\n .pres-segs {\n display: flex;\n gap: 2px;\n margin: 0 -10px;\n }\n\n /* The rule is a hairline; the BUTTON around it is not. A 2px-tall control is\n a 2px-tall pointer target, and on a finger it may as well not be there —\n so the hit area is a proper 16px band and the mark inside it stays a line. */\n .pres-seg {\n display: block;\n position: relative;\n height: 16px;\n flex: 1;\n margin: 0;\n padding: 0;\n border: 0;\n background: transparent;\n cursor: pointer;\n }\n\n .pres-seg::before {\n content: '';\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n height: 2px;\n margin-top: -1px;\n border-radius: 1px;\n background: color-mix(in srgb, var(--ink) 14%, transparent);\n transition: background 140ms var(--ease);\n }\n\n .pres-seg.is-seen::before {\n background: color-mix(in srgb, var(--copper) 58%, transparent);\n }\n\n .pres-seg.is-on::before {\n background: var(--ember-hot);\n }\n\n @media (hover: hover) {\n .pres-seg:hover::before {\n background: var(--ink-dim);\n }\n }\n\n .pres-foot {\n display: flex;\n flex: 1;\n align-items: center;\n gap: 8px;\n }\n\n /* Same grid cell so swapping folio ⇄ keys cannot resize the imprint — a\n reflow here moves every sprite and would restart a gated run. */\n .pres-meta {\n display: grid;\n flex: 1;\n min-width: 0;\n justify-items: start;\n align-items: center;\n }\n\n .pres-keys,\n .pres-folio {\n grid-area: 1 / 1;\n }\n\n .pres-keys {\n display: flex;\n visibility: hidden;\n align-items: center;\n gap: 10px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .pres.is-hot .pres-keys {\n visibility: visible;\n }\n\n .pres.is-hot .pres-folio {\n visibility: hidden;\n }\n\n .pres-keys span {\n display: flex;\n align-items: center;\n gap: 4px;\n }\n\n .pres-keys kbd {\n display: inline-block;\n padding: 1px 4px;\n border: 1px solid var(--line-strong);\n border-radius: 3px;\n font: inherit;\n letter-spacing: 0.08em;\n color: var(--copper-ink);\n }\n\n .pres-folio {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n /* the transition picker: a real <select>, so the four kinds are a list you\n read rather than a word you have to press four times to see */\n .pres-xfade {\n display: flex;\n flex-shrink: 0;\n align-items: center;\n gap: 6px;\n margin: 0;\n cursor: pointer;\n }\n\n .pres-xfade-tag {\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .pres-xfade select {\n appearance: none;\n padding: 4px 20px 4px 9px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background:\n /* the chevron, drawn rather than typed */\n linear-gradient(45deg, transparent 50%, currentcolor 50%)\n calc(100% - 11px) calc(50% - 1px) / 4px 4px no-repeat,\n linear-gradient(-45deg, currentcolor 50%, transparent 50%)\n calc(100% - 7px) calc(50% - 1px) / 4px 4px no-repeat;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n cursor: pointer;\n }\n\n @media (hover: hover) {\n .pres-xfade:hover select {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n\n /* Start over — the same pill as the arrows, wearing a word instead of a\n glyph. It reads as part of the transport rather than a fifth species. */\n .pres-restart {\n flex-shrink: 0;\n margin: 0;\n padding: 4px 9px;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: transparent;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .pres-restart:hover {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n @container (max-width: 420px) {\n .pres-foot {\n flex-wrap: wrap;\n row-gap: 6px;\n }\n }\n\n .pres-arr {\n flex-shrink: 0;\n display: grid;\n place-items: center;\n width: 21px;\n height: 21px;\n padding: 0;\n border: 1px solid var(--line-strong);\n border-radius: 999px;\n background: transparent;\n color: var(--ink-dim);\n font-size: 10px;\n line-height: 1;\n }\n\n @media (hover: hover) {\n .pres-arr:hover:not(:disabled) {\n color: var(--ink);\n border-color: var(--copper);\n }\n }\n\n .pres-arr:disabled {\n opacity: 0.28;\n cursor: default;\n }\n\n /* builds start invisible; the timeline writes them. Back-arrive skips the\n timeline and shows the still — the previous slide, already built. */\n .pres-slide .is-build {\n opacity: 0;\n }\n\n .pres-slide[data-arrive='back'] .is-build {\n opacity: 1;\n transform: none;\n }\n\n .pres-eye,\n .pres-title,\n .pres-kicker,\n .pres-stamp,\n .pres-num,\n .pres-plate,\n .pres-hed,\n .pres-line,\n .pres-idx,\n .pres-plot,\n .pres-cap,\n .pres-sys,\n .pres-close,\n .pres-seal {\n position: absolute;\n }\n\n .pres-eye,\n .pres-idx {\n top: 5.5cqw;\n left: var(--pres-m);\n margin: 0;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.16em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n /* 01 — the title.\n *\n * No figure on this slide: the contrast IS the design. One word solid, the\n * word under it outlined in Halo, at a step nothing else in the deck gets\n * near — 13cqw against a 1.8cqw eyebrow is better than 7:1, and that gap is\n * what makes a title slide read as a title rather than as a big heading. */\n .pres-title {\n top: 14cqw;\n left: var(--pres-m);\n margin: 0;\n font-family: var(--font-display-alt);\n font-weight: 700;\n font-stretch: 62%;\n font-variation-settings: 'wdth' 62;\n font-size: clamp(30px, 13cqw, 76px);\n letter-spacing: -0.045em;\n line-height: 0.84;\n text-transform: uppercase;\n }\n\n /* the counter-word: the same cut, drawn rather than filled */\n .pres-title.is-out {\n top: 25cqw;\n color: transparent;\n -webkit-text-stroke: 1.4px var(--sl-accent);\n }\n\n /* the rule that wipes out under the pair, on the same press */\n .pres-swipe {\n position: absolute;\n top: 38.5cqw;\n left: var(--pres-m);\n display: block;\n width: 32cqw;\n height: 2px;\n background: var(--sl-accent);\n transform-origin: 0 50%;\n }\n\n .pres-slide.is-title .pres-kicker {\n top: 43cqw;\n }\n\n .pres-kicker {\n top: 26cqw;\n left: var(--pres-m);\n margin: 0;\n width: fit-content;\n font-size: var(--pres-2);\n font-style: italic;\n color: var(--sl-dim);\n letter-spacing: -0.01em;\n }\n\n /* the mesh is 320x110 — a band. Its box carries the same ratio so the drawing\n fills it rather than floating in the middle of it. */\n .pres-plot {\n top: 25cqw;\n left: var(--pres-m);\n width: 86cqw;\n height: 29.6cqw;\n }\n\n /* on the split ground the head sits in the paper half and the figure runs\n across the cut, so the type gets the left 58% and nothing overhangs */\n .pres-slide.is-split .pres-hed {\n right: 44cqw;\n }\n /* the station labels cross the cut too, so the ones that land on steel take\n their contrast from that half rather than from the paper half's --sl-faint */\n .pres-slide.is-split .pres-stations li:nth-child(n + 4) {\n color: rgba(244, 246, 247, 0.78);\n }\n\n /* the two ends of the ride. Positioned in % of the CHART, which is itself a\n cqw box — so the bead sits on the same point of the curve at every size. */\n .pres-origin,\n .pres-hull {\n position: absolute;\n width: 1.3cqw;\n height: 1.3cqw;\n min-width: 6px;\n min-height: 6px;\n }\n\n .pres-origin {\n left: 3.75%;\n top: 65%;\n margin-top: -0.65cqw;\n }\n\n .pres-hull {\n left: 96.25%;\n top: 57.5%;\n margin-top: -0.65cqw;\n border-radius: 999px;\n background: var(--sl-accent);\n box-shadow:\n 0 0 0 0.55cqw color-mix(in srgb, var(--sl-accent) 24%, transparent),\n 0 0 12px color-mix(in srgb, var(--sl-accent) 60%, transparent);\n }\n\n .pres-stamp,\n .pres-seal {\n margin: 0;\n width: fit-content;\n font-family: var(--font-mono);\n font-weight: 500;\n font-size: var(--pres-1);\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n .pres-stamp {\n right: var(--pres-m);\n bottom: 5cqw;\n }\n\n .pres-seal {\n right: var(--pres-m);\n bottom: 6cqw;\n padding: 1.4cqw 1.8cqw 1.2cqw;\n border: 1px solid var(--sl-accent);\n border-radius: 999px;\n }\n\n /* 02 — the idea: a cropped numeral, a copper plate, one sentence.\n The numeral is not on the type scale: it is a graphic at 10% ink, cropped\n by two edges, and it is sized against the plate rather than the ladder. */\n .pres-num {\n right: -1cqw;\n top: -4cqw;\n margin: 0;\n font-family: var(--font-display-alt);\n font-weight: 700;\n font-stretch: 62%;\n font-variation-settings: 'wdth' 62;\n font-size: 17cqw;\n line-height: 0.8;\n letter-spacing: -0.06em;\n color: color-mix(in srgb, var(--ink) 10%, transparent);\n pointer-events: none;\n }\n\n .pres-plate {\n top: 0;\n right: 0;\n bottom: 0;\n width: 26cqw;\n background: linear-gradient(\n 160deg,\n #ff7a45 0%,\n #c42712 42%,\n #2a0c08 100%\n );\n box-shadow: -8px 0 24px\n rgba(var(--shadow-rgb), calc(0.3 * var(--shadow-a)));\n }\n\n .pres-hed,\n .pres-close {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n letter-spacing: -0.04em;\n line-height: 0.94;\n }\n\n .pres-hed {\n top: 13cqw;\n left: var(--pres-m);\n right: 34cqw;\n font-size: var(--pres-4);\n }\n\n .pres-hed.is-tight {\n top: 11cqw;\n right: 9cqw;\n }\n\n /* Plex Sans italic sets optically larger than Syne 800 at the same body\n size, so the counter-voice steps down to sit level with the display line\n instead of over-reaching it. */\n .pres-hed em,\n .pres-close em {\n display: block;\n font-family: var(--font);\n font-weight: 400;\n font-style: italic;\n font-size: 0.88em;\n letter-spacing: -0.02em;\n color: var(--sl-dim);\n }\n\n .pres-line {\n left: var(--pres-m);\n margin: 0;\n font-size: var(--pres-2);\n color: var(--sl-ink);\n letter-spacing: -0.01em;\n }\n\n .pres-verse {\n position: absolute;\n top: 28cqw;\n left: var(--pres-m);\n right: 34cqw;\n bottom: 5cqw;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1.2cqw;\n margin: 0;\n }\n\n .pres-cell {\n position: relative;\n display: grid;\n grid-template-columns: auto 1fr;\n grid-template-rows: auto auto;\n column-gap: 1.6cqw;\n row-gap: 0.6cqw;\n min-height: 0;\n }\n\n .pres-tick {\n grid-row: 1 / -1;\n align-self: start;\n margin: 0;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n font-weight: 500;\n letter-spacing: 0.14em;\n color: var(--sl-accent);\n }\n\n .pres-cell p {\n margin: 0;\n font-size: var(--pres-2);\n letter-spacing: -0.01em;\n }\n\n .pres-rule {\n grid-column: 2;\n display: block;\n height: 1px;\n width: 100%;\n max-width: 11em;\n margin: 0;\n background: var(--sl-accent);\n transform-origin: 0 50%;\n opacity: 0;\n }\n\n .pres-rule.is-on {\n opacity: 1;\n }\n\n .pres-inner .pres-tick,\n .pres-inner .pres-cell p,\n .pres-inner .pres-rule,\n .pres-inner .pres-sys-item,\n .pres-inner .pres-sys-item b,\n .pres-inner .pres-sys-item h3,\n .pres-inner .pres-sys-item p {\n opacity: 0;\n }\n\n .pres-stations {\n position: absolute;\n left: 2%;\n right: 2%;\n bottom: -14%;\n display: flex;\n justify-content: space-between;\n margin: 0;\n padding: 0;\n list-style: none;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-faint);\n }\n\n .pres-cap {\n right: var(--pres-m);\n bottom: 5cqw;\n left: auto;\n margin: 0;\n width: fit-content;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-accent);\n }\n\n /* 04 — the system.\n A band, not the whole plate: the cards used to sit in a box running to the\n bottom margin, which left the lower third empty because every card's\n content is top-aligned inside it. */\n .pres-sys {\n top: 15cqw;\n left: var(--pres-m);\n right: var(--pres-m);\n bottom: 7cqw;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 4cqw;\n margin: 0;\n padding: 0;\n list-style: none;\n }\n\n .pres-sys-item {\n display: flex;\n flex-direction: column;\n gap: 0.7cqw;\n min-width: 0;\n padding-top: 1.6cqw;\n border-top: 1px solid var(--sl-rule);\n }\n\n .pres-sys-item b {\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n font-weight: 500;\n letter-spacing: 0.16em;\n color: var(--sl-accent);\n }\n\n .pres-sys-item h3 {\n margin: 0;\n font-family: var(--font-display);\n font-weight: 800;\n font-size: var(--pres-3);\n letter-spacing: -0.035em;\n }\n\n /* number, title and rule hold the top of the band; the descriptions drop to\n a shared baseline at the bottom of it, the way a spec plate sets them */\n .pres-sys-item p {\n margin: auto 0 0;\n font-size: var(--pres-2);\n line-height: 1.35;\n color: var(--sl-dim);\n }\n\n /* 05 — the close */\n .pres-close {\n top: 14cqw;\n left: var(--pres-m);\n /* \"The system\" needs the full measure; a tighter inset breaks the line\n after \"The\", which is not a line break anyone chose */\n right: 16cqw;\n font-size: var(--pres-5);\n }\n\n .pres-slide[data-slide='4'] .pres-line {\n top: 38cqw;\n font-family: var(--font-mono);\n font-size: var(--pres-1);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--sl-faint);\n }\n\n .choreo-site:not([data-theme='light']) .pres-stage {\n background: #2a2521;\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSeconds('presentation', IN, 'IN duration');\ntuneSeconds('presentation', 0.52, 'Step 1 duration');\ntuneSeconds('presentation', 0.66, 'Step 2 duration');\ntuneSeconds('presentation', 0.62, 'Step 3 duration');\ntuneSeconds('presentation', PULSE, 'PULSE duration');\ntuneSeconds('presentation', 0.48, 'Step 4 duration');\ntuneSeconds('presentation', 0.56, 'Step 5 duration');\ntuneSeconds('presentation', 0.48, 'Step 6 duration');\ntuneSeconds('presentation', 0.5, 'Step 7 duration');\ntuneSeconds('presentation', 0.4, 'Step 8 duration');\ntuneSeconds('presentation', 0.52, 'Step 9 duration');\ntuneSeconds('presentation', 0.5, 'Step 10 duration');\ntuneSeconds('presentation', 0.4, 'Step 11 duration');\ntuneSeconds('presentation', 0.52, 'Step 12 duration');\ntuneSeconds('presentation', 0.5, 'Step 13 duration');\ntuneSeconds('presentation', 0.4, 'Step 14 duration');\ntuneSeconds('presentation', 0.52, 'Step 15 duration');\ntuneSeconds('presentation', FLIGHT, 'FLIGHT duration');\ntuneSeconds('presentation', 0.2, 'Step 16 duration');\ntuneSeconds('presentation', GROUP, 'GROUP duration');\ntuneSeconds('presentation', 0.5, 'Step 17 duration');\ntuneSeconds('presentation', 0.36, 'Step 18 duration');\ntuneSeconds('presentation', 0.5, 'Step 19 duration');\ntuneSeconds('presentation', 0.36, 'Step 20 duration');\ntuneSeconds('presentation', 0.5, 'Step 21 duration');\ntuneSeconds('presentation', 0.36, 'Step 22 duration');\ntuneSeconds('presentation', 0.48, 'Step 23 duration');\n\nexport class PresentationDemo extends GalleryDemo {\n static stage = Presentation;\n static notes = PresentationNotes;\n}\n";
514
514
  //#endregion
515
515
  //#region ../choreo-gallery/realm/stages/rack.gts?raw
516
- var rack_default = "import type { ChoreoRun, Sprite } from '@cardstack/choreo';\nimport { Choreo } from '@cardstack/choreo';\nimport { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { afterSettle, motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneSeconds } from '../lib/tuning';\nimport RackNotes from '../notes/rack';\n\n/**\n * The four arrangements. The index IS the slider's whole number.\n *\n * 0 `alpha` A to Z, reading order\n * 1 `groups` the five vowels lifted out of the twenty-one consonants\n * 2 `bag` stacked by how many of that tile a Scrabble set contains\n * 3 `points` stacked by what the tile is worth\n * 4 `plot` frequency against points, with axes\n * 5 `game` the whole set played out on a board\n *\n * `points` and `plot` share an x axis on purpose, so the last leg is a purely\n * vertical spread out of the stacks. `bag` comes before them rather than\n * after for the same reason: it has an x axis of its own, and putting it\n * between the two would break the one they share.\n */\nconst STOPS = ['A–Z', 'Vowels', 'Bag', 'Points', 'Plot', 'Game'];\nconst LAST = STOPS.length - 1;\nconst STOP_ZERO = STOPS[0];\n\n/**\n * ONE SCORE, NOT THREE.\n *\n * A changeset has exactly two ends, so a journey through four poses looks at\n * first like three scores with the host swapping between them at each notch.\n * That is how the first two versions of this demo worked and it is a bad\n * trade: every swap needs a render round-trip, the swap happens at a notch\n * while the outgoing run is still holding its sprites, and a gesture can\n * outrun the whole arrangement.\n *\n * None of it is necessary, because a keyframe array is the from-and-to AND\n * ANY WAYPOINTS in one value. Four stops per property is one score for the\n * whole journey. The DOM never changes: no pose classes, no re-render, no\n * changeset to depend on — the region compiles once on mount and the slider\n * scrubs its clock from end to end.\n *\n * The prices are worth naming. The poses have to be arithmetic rather than\n * stylesheet, since nothing can be measured out of a layout that is never\n * rendered. And with the tiles' geometry stated rather than measured, the\n * whole \"positioned against its own size\" family cannot arise: every tile is\n * the same 28px square in all four arrangements and only its transform moves.\n */\nconst SPAN = 1.8;\nconst LINEAR = 'linear' as const;\nconst HOME = { damping: 24, stiffness: 240 } as const;\n\n/** relative frequency of each letter in English text, in percent */\nconst FREQ: Record<string, number> = {\n a: 8.17,\n b: 1.29,\n c: 2.78,\n d: 4.25,\n e: 12.7,\n f: 2.23,\n g: 2.02,\n h: 6.09,\n i: 6.97,\n j: 0.15,\n k: 0.77,\n l: 4.03,\n m: 2.41,\n n: 6.75,\n o: 7.51,\n p: 1.93,\n q: 0.1,\n r: 5.99,\n s: 6.33,\n t: 9.06,\n u: 2.76,\n v: 0.98,\n w: 2.36,\n x: 0.15,\n y: 1.97,\n z: 0.07,\n};\n\n/** what the tile is worth in Scrabble */\nconst POINTS: Record<string, number> = {\n a: 1,\n b: 3,\n c: 3,\n d: 2,\n e: 1,\n f: 4,\n g: 2,\n h: 4,\n i: 1,\n j: 8,\n k: 5,\n l: 1,\n m: 3,\n n: 1,\n o: 1,\n p: 3,\n q: 10,\n r: 1,\n s: 1,\n t: 1,\n u: 1,\n v: 4,\n w: 4,\n x: 8,\n y: 4,\n z: 10,\n};\n\nconst VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);\n\n/**\n * The blank.\n *\n * A set holds two, but one on the board says the same thing and a pair of\n * identical featureless tiles reads as a mistake rather than as a fact. It is\n * worth nothing and stands for anything. They belong in three of the five\n * arrangements and genuinely do not belong in the fourth: a blank has no\n * letter, so it has no letter frequency, and putting it anywhere on that\n * chart would be inventing a number.\n *\n * So it leaves. Its opacity keyframes run 1,1,1,1,0 and it fades out over the\n * final leg while everything else spreads — which says what the footnote\n * under a chart would have had to say, without a footnote.\n */\nconst BLANKS = ['blank'];\n\n/** how many of each tile a Scrabble set contains */\nconst BAG: Record<string, number> = {\n a: 9,\n b: 2,\n c: 2,\n d: 4,\n e: 12,\n f: 2,\n g: 3,\n h: 2,\n i: 9,\n j: 1,\n k: 1,\n l: 4,\n m: 2,\n n: 6,\n o: 8,\n p: 2,\n q: 1,\n r: 6,\n s: 4,\n t: 6,\n u: 4,\n v: 2,\n w: 2,\n x: 1,\n y: 2,\n z: 1,\n};\n\n/**\n * CWM FJORD BANK GLYPHS VEXT QUIZ.\n *\n * A perfect pangram: six words, twenty-six letters, not one of them twice.\n * (A cwm is a steep hollow on a hillside; vext is the archaic spelling of\n * vexed. Both are good in Scrabble.) Nothing crosses — they are laid out as\n * separate blocks — which is what makes it placeable at all: an interlocking\n * version of the same idea is not, and I have the exhaustive search to prove\n * it. Every letter has to be used exactly once, so VET and BAN would each\n * need BOTH their repeated letters already adjacent on the board, and no\n * arrangement of those words provides it.\n *\n * The two blanks are not played. They sit in the corner, which is the honest\n * place for a tile nobody needed.\n */\nconst GAME: [string, number, number][] = [\n // word, column, row — staggered, because a board someone has played on is\n // not six centred rows. Centring them was worse than it sounds: an\n // even-length word cannot be centred on an odd grid without sitting half a\n // square off, so four of the six looked misaligned rather than placed.\n ['CWM', 1, 0],\n ['FJORD', 5, 2],\n ['BANK', 0, 4],\n ['GLYPHS', 4, 6],\n ['VEXT', 1, 8],\n ['QUIZ', 6, 10],\n];\nconst GRID = 11;\n\n/* ── the layouts, arithmetic rather than stylesheet ─────────────────────── */\n\nconst TILE = 22;\nconst STEP = 27;\nconst COLS = 7;\nconst BOARD = { h: 292, w: 340 };\n/** the board's own pitch — one square per cell, a tile centred in each */\nconst CELL = 22;\nconst BOARD_X = Math.round((BOARD.w - GRID * CELL) / 2);\nconst BOARD_Y = 8;\nconst square = (col: number, row: number) => ({\n x: BOARD_X + col * CELL + (CELL - TILE) / 2,\n y: BOARD_Y + row * CELL + (CELL - TILE) / 2,\n});\nconst GRID_X = Math.round((BOARD.w - (COLS - 1) * STEP - TILE) / 2);\n\nconst alphabet = Object.keys(FREQ);\n/** the full set: twenty-six letters and a blank */\nconst bagful = [...alphabet, ...BLANKS];\nconst isBlank = (ch: string) => ch.startsWith('blank');\nconst topFreq = Math.max(...Object.values(FREQ));\nfor (const b of BLANKS) {\n POINTS[b] = 0;\n BAG[b] = 2;\n}\n/**\n * Both axes run the WHOLE range, including the values nothing has.\n *\n * Listing only the values that occur — 1, 2, 3, 4, 6, 8, 9, 12 — spaces them\n * evenly and quietly lies: it makes the step from 4 to 6 look like the step\n * from 1 to 2, and hides that there is no letter you hold six of. An ordinal\n * axis with empty columns says the true shape, and the gaps are part of it.\n */\nconst range = (lo: number, hi: number) =>\n Array.from({ length: hi - lo + 1 }, (_, i) => lo + i);\n/** points start at ZERO, because the blanks are worth nothing and are real */\nconst values = range(0, Math.max(...Object.values(POINTS)));\nconst counts = range(1, Math.max(...Object.values(BAG)));\n\nconst cell = (n: number, x0: number, y0: number) => ({\n x: x0 + (n % COLS) * STEP,\n y: y0 + Math.floor(n / COLS) * STEP,\n});\n\n/**\n * Two arrangements share one x axis, and that is the point.\n *\n * `points` stacks the tiles into a column per value — a distribution you can\n * read at a glance: ten letters are worth 1, two are worth 10. `plot` keeps\n * every tile in the same column and lets it find its true height. So the last\n * leg of the journey is a purely VERTICAL spread out of the stacks, which is\n * the clearest way to show what the chart is actually claiming: how often a\n * letter turns up, against what it is worth.\n */\nconst PLOT = { bottom: 256, left: 16, right: 332, top: 14 };\n\n/** every letter of a given value, most frequent first */\nconst strip = (value: number) =>\n bagful\n .filter((c) => POINTS[c] === value)\n .sort((a, b) => (FREQ[b] ?? -1) - (FREQ[a] ?? -1));\n\n/**\n * The points axis gives each column the width its CROWD needs.\n *\n * A tile can be worth 1, 2, 3, 4, 5, 8 or 10, and the letters are not spread\n * evenly across those: ten are worth 1, and only one is worth 5. Spaced\n * evenly, ten tiles fight over a seventh of the board while a single K gets\n * the same room — and no amount of lane-shuffling fixes it, because ten 28px\n * tiles do not fit in 35px however they are arranged. A log axis was the\n * first attempt and it made the far end worse: it squeezed 8 and 10 to 25px\n * apart, and J and X, which have exactly the same frequency, had nowhere to\n * stand but on top of each other.\n *\n * So the axis is ordinal and each value's slot is proportional to how many\n * letters it holds, with a floor so a lone K still gets a place to stand.\n * The ticks are labelled, so the axis stays readable without being linear.\n */\nconst SPAN_X = PLOT.right - PLOT.left;\n\n/**\n * Columns are EVENLY spaced, and the crowding is absorbed by depth instead.\n *\n * Widths proportional to population were tried and read as a broken ruler:\n * the gap from 1 to 2 was four times the gap from 8 to 10, so the axis\n * implied a scale it did not have. An even axis is honest and legible, and\n * where a column holds more than it can stack the pile simply wraps into a\n * second file — the crowd shows as depth rather than as width.\n */\nfunction lanesOf(n: number) {\n const width = SPAN_X / n;\n return {\n centres: Array.from({ length: n }, (_, i) => PLOT.left + (i + 0.5) * width),\n width,\n };\n}\n\nconst PTS_LANES = lanesOf(values.length);\nconst COLUMN = (value: number) =>\n PTS_LANES.centres[values.indexOf(value)]! - TILE / 2;\n\n/**\n * A stack's row pitch, which CLEARS the tile.\n *\n * Overlapping piles were tried and read as a mess rather than as a stack: a\n * tile half-covering the one below hides the value in its corner, which is\n * the number the arrangement is about. Nothing overlaps now, which sets the\n * board's height rather than the other way round — eleven tiles is the\n * deepest column there is, and the board is tall enough for eleven.\n */\nconst STACK = TILE + 3;\n\n/** where the k-th tile of a pile sits, counting up from the baseline */\nconst pile = (x0: number, k: number) => ({\n x: x0,\n y: PLOT.bottom - TILE - k * STACK,\n});\n\nconst stacked = new Map<string, { x: number; y: number }>();\nconst bagged = new Map<string, { x: number; y: number }>();\nconst BAG_LANES = lanesOf(counts.length);\nfor (const count of counts) {\n const x = BAG_LANES.centres[counts.indexOf(count)]! - TILE / 2;\n const members = bagful.filter((c) => BAG[c] === count);\n members.forEach((ch, k) => {\n bagged.set(ch, pile(x, k));\n });\n}\nconst plotted = new Map<string, { x: number; y: number }>();\n\n/**\n * The frequency axis runs DOWNWARD — the most common letters lie along the\n * bottom, the rare ones climb away from it — and it is a SQUARE-ROOT scale.\n *\n * English is lopsided: E alone is 12.7% and half the alphabet is under 2%, so\n * on a linear axis twenty letters pile into one eighth of the board. Rooting\n * the ratio pulls that crowd apart without reordering anybody.\n *\n * Collisions are then resolved by SEARCH rather than by a fixed set of lanes,\n * because the crowding is not uniform: one column needs three lanes and the\n * next needs none. Each tile takes its true height and the first free spot\n * near it, preferring sideways movement — height is the variable being read,\n * so it is the one that must stay honest — and giving ground vertically only\n * when a column has no width left. J and X, which have identical frequency,\n * are the case that forces the last resort to exist.\n */\nconst placed: { x: number; y: number }[] = [];\nconst clear = (x: number, y: number) =>\n placed.every(\n (q) => Math.abs(q.x - x) >= TILE - 2 || Math.abs(q.y - y) >= TILE - 2,\n );\n\nfor (const value of values) {\n const x0 = COLUMN(value);\n const members = strip(value);\n // stacked: most frequent on the bottom row, matching where the plot will\n // put it, so nothing has to cross on the way out\n members.forEach((ch, k) => {\n stacked.set(ch, pile(x0, k));\n });\n\n for (const ch of members) {\n // A BLANK IS THE OUTLIER, not an omission.\n //\n // It has no letter, so it has no letter frequency — but it can stand for\n // any letter, which means it plays as often as the commonest one does.\n // Worth nothing and worth everything: zero points, and as useful as E.\n // Dropping it off the chart would have been the tidier lie.\n const y0 = isBlank(ch)\n ? PLOT.top + (PLOT.bottom - PLOT.top) - TILE / 2\n : PLOT.top +\n Math.sqrt(FREQ[ch]! / topFreq) * (PLOT.bottom - PLOT.top) -\n TILE / 2;\n // Widen the search until something is clear.\n //\n // Sideways first and as far as a whole tile, because height is the\n // variable being read and must stay honest for as long as possible; then,\n // and only then, small vertical give. A column is narrower than a tile\n // here, so a fan that stayed inside its own column could not separate\n // anybody — the collision test is what keeps neighbours apart, not the\n // column's width.\n let spot = { x: x0, y: y0 };\n search: for (const dy of [0, -9, 9, -18, 18, -27, 27]) {\n for (const dx of [0, -0.45, 0.45, -0.9, 0.9, -1.35, 1.35]) {\n const x = x0 + dx * TILE;\n if (x >= PLOT.left - TILE && x <= PLOT.right && clear(x, y0 + dy)) {\n spot = { x, y: y0 + dy };\n break search;\n }\n }\n }\n placed.push(spot);\n plotted.set(ch, spot);\n }\n}\n\n/** every letter's square, worked out from the six words */\nconst played = new Map<string, { x: number; y: number }>();\nfor (const [word, col, row] of GAME) {\n [...word].forEach((ch, j) => {\n played.set(ch.toLowerCase(), square(col + j, row));\n });\n}\n// the tile nobody played, in the corner\nBLANKS.forEach((b, k) => played.set(b, square(GRID - 1 - k, GRID - 1)));\n\n/**\n * The top row of the `groups` arrangement: the vowels, and then the blanks.\n *\n * A blank is neither a vowel nor a consonant, and giving it a line of its own\n * under the consonant block put it where nothing else was — which read as a\n * mistake and, worse, put the two of them on the same square. They belong at\n * the END of the vowels: the tiles that can be any vowel you like, after the\n * five that are.\n */\nconst TOP_ROW = [...alphabet.filter((c) => VOWELS.has(c)), ...BLANKS];\n\n/** centre a row of `n` tiles on the board */\nconst row = (rank: number, n: number, y: number) => ({\n x: Math.round((BOARD.w - (n - 1) * STEP - TILE) / 2) + rank * STEP,\n y,\n});\n\nconst letters = bagful.map((ch, i) => {\n const blank = isBlank(ch);\n const vowel = VOWELS.has(ch);\n const top = TOP_ROW.indexOf(ch);\n const rank =\n top >= 0 ? top : bagful.filter((c) => TOP_ROW.indexOf(c) < 0).indexOf(ch);\n const sack = bagged.get(ch)!;\n const pile = stacked.get(ch)!;\n const spot = plotted.get(ch)!;\n return {\n blank,\n ch: blank ? '' : ch,\n key: ch,\n share: FREQ[ch] ?? 0,\n vowel,\n worth: POINTS[ch]!,\n /** the tile's seat in each of the five arrangements, in order */\n seats: [\n cell(i, GRID_X, 40),\n top >= 0 ? row(rank, TOP_ROW.length, 26) : cell(rank, GRID_X, 96),\n sack,\n pile,\n spot,\n played.get(ch)!,\n ],\n };\n});\n\nconst seats = new Map(letters.map((l) => [l.key, l.seats]));\n\nconst clamp = (v: number, lo: number, hi: number) =>\n v < lo ? lo : v > hi ? hi : v;\n\n/**\n * A detent under the finger: the playhead is drawn toward whichever stop it\n * is nearest, hardest when it is nearly there.\n *\n * Not a snap — the drag stays continuous and every value in between is still\n * reachable — but a stop becomes slightly sticky, so letting go anywhere near\n * one leaves the arrangement dead centre rather than a few percent past it. A\n * rack with notches in it should feel like it has notches in it.\n */\nconst detent = (p: number) => {\n const near = Math.round(p);\n const off = p - near;\n // pull only inside a quarter of a stop, and taper to nothing at the edge\n const reach = 0.25;\n if (Math.abs(off) >= reach) {\n return p;\n }\n const pull = 1 - Math.abs(off) / reach;\n return p - off * pull * 0.55;\n};\n\n/** easeInOutQuint — still, then quick, then a long settle */\nconst quint = (t: number) =>\n t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2;\n\n/**\n * A slider IS a playhead, so this one drives a score directly.\n *\n * `/sheet` is the other half of the idea. There, `drag=\"y\"` owns the sheet's\n * position while the finger is down and the tiles do not move at all until\n * release, when a class change and `layout=true` spring them into shape.\n * Measure it mid-drag and the tile is the same width the whole way. That is\n * not a bug: `layout=true` is measure-and-fire, and there is nowhere to hand\n * it a progress. A Choreo run has one, because a run is a value with a clock\n * on it.\n *\n * WHY LETTERS. A morph is only worth scrubbing if the eye can follow it, and\n * that needs the things being moved to be individually nameable. Twenty-six\n * identical cards flying between a list and a grid is noise however exact the\n * arithmetic — every item crosses every other and none of them is anyone in\n * particular. Twenty-six lettered tiles crossing is a sort you can read: E\n * leaves the middle of the alphabet, keeps its seat when the vowels gather,\n * and ends up alone at the top of the plot, and you can watch it go.\n */\nexport class Rack extends Component {\n /**\n * THE PARKED OPENING.\n *\n * A region compiles a score from a PASS, and a pass is a render that\n * changes something inside it. This demo's DOM never changes — that is the\n * whole point of putting the journey in keyframes — so left alone the\n * region has nothing to react to and no run is ever built. One bump of a\n * counter after mount is enough: it changes nothing anyone can see, the\n * region measures a changeset, the score compiles, and the gate parks it at\n * zero before a frame of it can play. From then on the slider owns the\n * clock and nothing renders again.\n */\n @tracked private take = 0;\n\n /** the region's context. Not tracked — read from pointer handlers */\n private c?: { run: ChoreoRun | null };\n /** the slider, in notches */\n private p = 0;\n /** the stop the control is heading for — what a repeated key steps from */\n private goal = 0;\n private rail?: HTMLElement;\n private thumb?: HTMLElement;\n private dragging = false;\n private raf = 0;\n private v = 0;\n\n private get run() {\n return this.c?.run ?? null;\n }\n\n /**\n * Park the clock where the slider says.\n *\n * The clamp at the top is not cosmetic. A run allowed to reach its own\n * duration is FINISHED, and a finished run replays itself on any later\n * render — so a scrubbable run is parked a hair short of the end and never\n * allowed to retire.\n */\n private draw() {\n this.paint();\n const run = this.run;\n if (!run) {\n return;\n }\n // the head gate held the clock at zero; open it once, or every seek\n // parks right back on it\n if (run.parked) {\n run.advance();\n }\n run.pause();\n run.time = (this.p / LAST) * Math.max(0, run.duration - 0.001);\n }\n\n /**\n * The thumb, the filled track and the active label — all written straight\n * from the playhead, none of them measured, none of them tracked.\n *\n * Marking the nearest stop is the sort of thing that wants a `@tracked`\n * index, and it must not have one: the rail lives outside the region but\n * the component does not, so a re-render would reach the <Choreo> block and\n * a render inside a region is a PASS. A class toggle costs nothing and\n * cannot recompile a score.\n */\n private paint() {\n const f = this.p / LAST;\n // a fraction, not a percentage: the stylesheet turns it into a position\n // inside the track's span rather than the rack's\n this.thumb?.style.setProperty('--f', `${f}`);\n this.rail?.style.setProperty('--at', `${f * 100}%`);\n const near = Math.round(this.p);\n if (near !== this.lit) {\n this.lit = near;\n this.rail?.setAttribute('aria-valuenow', `${near}`);\n this.rail?.setAttribute('aria-valuetext', STOPS[near] ?? '');\n this.rail\n ?.querySelectorAll<HTMLElement>('.rk-notch')\n .forEach((el, i) => el.classList.toggle('is-on', i === near));\n }\n }\n\n /** which stop is currently marked, so the class is only rewritten on change */\n private lit = -1;\n\n /**\n * Hold the run the moment it exists — which is later than you would think.\n *\n * Modifiers on the region's CHILDREN run before the region's own update, so\n * when this fires `c.run` is still the previous pass's run. `afterSettle`\n * is the engine's post-render microtask: after the score exists, still\n * before paint.\n */\n wire = modifier((_el: Element, [c]: [{ run: ChoreoRun | null }]) => {\n this.c = c;\n this.draw();\n afterSettle(() => this.draw());\n });\n\n private stop() {\n cancelAnimationFrame(this.raf);\n this.raf = 0;\n }\n\n /**\n * Where a pointer at `clientX` puts the playhead.\n *\n * Measured against the TRACK, not the rack around it. The rack is padded,\n * so a fraction of its width lands short at one end and past the other —\n * which is exactly how the marker ended up sitting off the end of the wood\n * at the last stop.\n */\n private at(clientX: number) {\n const track = this.rail?.querySelector('.rk-track');\n if (!track) {\n return this.p;\n }\n const box = track.getBoundingClientRect();\n return clamp(((clientX - box.x) / box.width) * LAST, 0, LAST);\n }\n\n grab = (untyped: Event) => {\n // the realm's `{{on}}` types every listener's argument as a plain Event\n const event = untyped as PointerEvent;\n if (event.button !== 0) {\n return;\n }\n // A STOP IS NOT THE TRACK.\n //\n // The labels are children of the rack, so a press on one arrives here\n // first and would seize the playhead to wherever that label sits — the\n // ride is then over before `pick` gets to ask for it, and what you see is\n // a jump with an ease politely appended. A press on a stop belongs to the\n // stop; the rack only owns presses on itself.\n if ((event.target as Element | null)?.closest('.rk-notch')) {\n return;\n }\n this.stop();\n this.v = 0;\n this.dragging = true;\n (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);\n this.p = this.at(event.clientX);\n this.draw();\n };\n\n move = (untyped: Event) => {\n const event = untyped as PointerEvent;\n if (!this.dragging) {\n return;\n }\n const next = detent(this.at(event.clientX));\n this.v = (next - this.p) * 60;\n this.p = next;\n this.draw();\n };\n\n /**\n * The thumb lets go, and a spring takes the clock.\n *\n * `/sheet` hands a POSITION to a spring on release, and picks its detent\n * from where the throw was going rather than where the finger stopped. Same\n * rule; the value the spring settles is the playhead, and a notch is a\n * whole number on it.\n */\n land = (untyped: Event) => {\n const event = untyped as PointerEvent;\n if (!this.dragging) {\n return;\n }\n this.dragging = false;\n (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);\n const landing = Math.round(clamp(this.p + this.v * 0.12, 0, LAST));\n this.goal = landing;\n this.springTo(landing);\n };\n\n /**\n * Aim at a stop.\n *\n * From REST it is an eased ride: a tap or a key press has no momentum to\n * spend, so it gets a curve instead. Mid-RIDE it hands over to the spring\n * with whatever speed it already had — pressing an arrow twice quickly\n * should read as one longer journey, and restarting the ease each time\n * made it stutter, decelerating into a stop it never reached before\n * setting off again.\n */\n private aim(target: number) {\n if (this.raf) {\n this.goal = target;\n this.springTo(target);\n return;\n }\n this.easeTo(target);\n }\n\n /**\n * A stop, tapped: an eased ride rather than a spring.\n *\n * A throw has momentum and a spring is the honest way to spend it. A tap\n * has none — there is nothing to carry — so it gets a curve instead, and\n * quint is the one that reads as deliberate at this distance: a still\n * start, a quick middle, a long settle. The knob is not animated\n * separately; it is drawn from the playhead every frame, so it rides the\n * same curve as the tiles by construction.\n */\n private easeTo(target: number) {\n this.stop();\n this.goal = target;\n const from = this.p;\n const span = Math.abs(target - from);\n if (span < 0.001) {\n return;\n }\n // longer for a longer journey, but not proportionally — crossing four\n // stops should not take four times as long as crossing one\n const ms = 380 + Math.sqrt(span) * 300;\n const started = performance.now();\n let last = started;\n let lastP = from;\n const tick = (now: number) => {\n const t = Math.min(1, (now - started) / ms);\n this.p = from + (target - from) * quint(t);\n // keep a real velocity: an ease that reports zero has nothing to hand\n // over, and a second key press mid-ride then restarts from a dead stop\n const dt = Math.max(1, now - last) / 1000;\n this.v = (this.p - lastP) / dt;\n last = now;\n lastP = this.p;\n this.draw();\n if (t >= 1) {\n this.p = target;\n this.v = 0;\n this.draw();\n this.raf = 0;\n return;\n }\n this.raf = requestAnimationFrame(tick);\n };\n this.raf = requestAnimationFrame(tick);\n }\n\n /** carry the slider to a stop on a spring, carrying the throw's momentum */\n private springTo(target: number) {\n this.stop();\n this.goal = target;\n let last = performance.now();\n const tick = (now: number) => {\n const dt = Math.min((now - last) / 1000, 1 / 30);\n last = now;\n const a = HOME.stiffness * (target - this.p) - HOME.damping * this.v;\n this.v += a * dt;\n this.p = clamp(this.p + this.v * dt, 0, LAST);\n this.draw();\n if (Math.abs(target - this.p) < 0.002 && Math.abs(this.v) < 0.04) {\n this.p = target;\n this.v = 0;\n this.draw();\n this.raf = 0;\n return;\n }\n this.raf = requestAnimationFrame(tick);\n };\n this.raf = requestAnimationFrame(tick);\n }\n\n /**\n * Arrow keys walk the stops, Home and End jump to the ends.\n *\n * The same eased ride a tap gets, for the same reason: a key press has no\n * momentum to spend. A rack that can be dragged but not tabbed to is a\n * control only half of the people who need it can use.\n */\n key = (untyped: Event) => {\n const event = untyped as KeyboardEvent;\n // Walk from the GOAL, not from where the playhead happens to be.\n //\n // Deriving the next stop from `Math.round(this.p)` looks equivalent and\n // is not: a held key repeats while the previous ride is still in the air,\n // the playhead is still rounding to the stop it left, and every repeat\n // re-targets the same destination. The rack stalls one stop short and\n // then completes when you let go, which is exactly backwards.\n const step: Record<string, number> = {\n ArrowDown: this.goal - 1,\n ArrowLeft: this.goal - 1,\n ArrowRight: this.goal + 1,\n ArrowUp: this.goal + 1,\n End: LAST,\n Home: 0,\n };\n const target = step[event.key];\n if (target === undefined) {\n return;\n }\n event.preventDefault();\n this.aim(clamp(target, 0, LAST));\n };\n\n /** a notch, clicked: the same spring, a stated target */\n pick = (untyped: Event) => {\n const event = untyped as MouseEvent;\n const notch = Number(\n (event.currentTarget as HTMLElement).dataset['notch'] ?? 0,\n );\n this.aim(clamp(notch, 0, LAST));\n };\n\n /**\n * Clicking anywhere in the demo hands the keyboard to the rack.\n *\n * The rack is the slider and holds the focus, but nobody clicks a rack to\n * start using arrow keys — they click the thing they are looking at, which\n * is the tiles. Focusing on any pointerdown in the card means the arrows\n * work the moment you have touched it at all. `preventScroll`, because\n * focusing something should never move the page under you.\n */\n claim = () => {\n this.rail?.focus({ preventScroll: true });\n };\n\n railed = modifier((el: HTMLElement) => {\n this.rail = el;\n // the first stop is where the playhead already is, so it has to read as\n // selected before anyone touches anything — `paint` only writes the class\n // on a change, and until the rack exists there is nothing to write to\n this.lit = -1;\n this.paint();\n return () => {\n this.rail = undefined;\n };\n });\n\n thumbed = modifier((el: HTMLElement) => {\n this.thumb = el;\n this.paint();\n return () => {\n this.thumb = undefined;\n };\n });\n\n mount = modifier((el: HTMLElement) => {\n (el as HTMLElement & { rack?: Rack }).rack = this;\n const id = requestAnimationFrame(() => {\n this.take = 1;\n });\n return () => {\n cancelAnimationFrame(id);\n this.stop();\n };\n });\n\n /* ── the score's keyframes: one array per property, four stops long ──── */\n\n /** every seat's x, in order — the tile's whole journey in one value */\n xs = (sprite: Sprite) => (seats.get(sprite.id ?? '') ?? []).map((s) => s.x);\n\n ys = (sprite: Sprite) => (seats.get(sprite.id ?? '') ?? []).map((s) => s.y);\n\n /**\n * The axes arrive in two stages, because they mean two different things.\n *\n * The value axis is already true at `points` — the stacks stand on it — so\n * it draws in over the third leg and stays. The frequency axis is only\n * true once the tiles have left their stacks, so it waits for the fourth.\n */\n /**\n * Each piece of furniture fades in only where it is TRUE.\n *\n * A legend that is always on screen is decoration; one that arrives exactly\n * when the arrangement starts making its claim is part of the claim. Every\n * array below is one number per stop, so the whole schedule of what is\n * being asserted is readable in five columns.\n *\n * A–Z Vowels Bag Points Plot Game\n */\n fadeVowels = [0, 1, 0, 0, 0, 0];\n fadeBag = [0, 0, 1, 0, 0, 0];\n fadePoints = [0, 0, 0, 1, 1, 0];\n fadePlot = [0, 0, 0, 0, 1, 0];\n fadeGame = [0, 0, 0, 0, 0, 1];\n /**\n * The blanks are part of the SET, not of the last two claims.\n *\n * They belong in A–Z, in the vowel split and in both distributions,\n * because a set contains them. They have no letter frequency to plot and\n * no part in a pangram that uses every letter exactly once, so they leave\n * before those two rather than stand around implying otherwise.\n */\n fadeBlank = [1, 1, 1, 1, 0, 0];\n\n /** test-support: what the slider and the score are doing */\n get debug() {\n const run = this.run;\n return {\n duration: run?.duration ?? null,\n p: this.p,\n time: run?.time ?? null,\n };\n }\n\n /** test-support: put the slider somewhere, as the thumb would */\n seek(p: number) {\n this.stop();\n this.p = clamp(p, 0, LAST);\n this.goal = Math.round(this.p);\n this.draw();\n }\n\n letters = letters;\n values = values;\n counts = counts;\n\n <template>\n <div\n class='ex no-select'\n {{on 'selectstart' preventSelect}}\n {{on 'pointerdown' this.claim}}\n {{this.mount}}\n >\n <div class='rk-fit-box'><div class='rk-fit'>\n {{! the region is the stage; the tiles are children ON it }}\n <Choreo class='rk-stage' as |c|>\n <span\n class='rk-wire'\n data-take={{this.take}}\n {{this.wire c}}\n ></span>\n\n {{! Every piece of furniture belongs to ONE arrangement and fades in\n where it becomes true — see the fade arrays above. Nothing is ever\n added or removed: a changeset that gains a participant mid-gesture\n is a different kind of pass. }}\n <span class='rk-key is-v' {{motion id='key-v' role='vowels'}}>\n Vowels\n </span>\n <span class='rk-key is-c' {{motion id='key-c' role='vowels'}}>\n Consonants\n </span>\n\n <span\n class='rk-axis is-x'\n {{motion id='axis-bag' role='bag'}}\n ></span>\n <span class='rk-alab is-x' {{motion id='lab-bag' role='bag'}}>\n Tiles in the bag\n </span>\n {{#each this.counts as |count|}}\n <span\n class='rk-tick'\n style={{tickAt count this.counts}}\n {{motion id=(tickId 'bag' count) role='bag'}}\n >{{count}}</span>\n {{/each}}\n\n <span\n class='rk-axis is-x'\n {{motion id='axis-x' role='points'}}\n ></span>\n <span class='rk-alab is-x' {{motion id='lab-x' role='points'}}>\n Scrabble points\n </span>\n {{#each this.values as |value|}}\n <span\n class='rk-tick'\n style={{tickAt value this.values}}\n {{motion id=(tickId 'pts' value) role='points'}}\n >{{value}}</span>\n {{/each}}\n\n {{! the board, which is only true at the last stop }}\n <span class='rk-grid' {{motion id='grid' role='game'}}></span>\n <span class='rk-alab is-game' {{motion id='lab-game' role='game'}}>\n Cwm fjord bank glyphs vext quiz · 26 tiles, no letter twice\n </span>\n\n <span\n class='rk-axis is-y'\n {{motion id='axis-y' role='plot'}}\n ></span>\n <span class='rk-alab is-yt' {{motion id='lab-yt' role='plot'}}>\n ↑ Rarer\n </span>\n <span class='rk-alab is-yb' {{motion id='lab-yb' role='plot'}}>\n ↓ Commoner\n </span>\n\n {{#each this.letters as |l|}}\n <span\n class='rk-l {{if l.vowel \"is-vowel\"}} {{if l.blank \"is-blank\"}}'\n {{motion id=l.key role=(if l.blank 'blank' 'tile')}}\n >\n <b>{{l.ch}}</b>\n {{#unless l.blank}}<i>{{l.worth}}</i>{{/unless}}\n </span>\n {{/each}}\n\n <c.Sequence>\n {{! the gate is what keeps the score still until the slider asks }}\n <c.Gate />\n <c.Parallel>\n {{! Four stops, evenly spaced along one clock. Linear, because\n under a finger there is no time to have an opinion about —\n the hand is already the curve. }}\n {{! A role is a single value, not a list, so the blanks cannot be\n both \"tile\" and \"blank\". `@of` takes an ARRAY of queries, which\n is the honest way to say \"everything that moves\": the letters\n and the blanks travel together, and only the blanks also\n fade. }}\n <c.Tween\n @of={{array (c.role 'tile') (c.role 'blank')}}\n @x={{this.xs}}\n @y={{this.ys}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'vowels'}}\n @opacity={{this.fadeVowels}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'bag'}}\n @opacity={{this.fadeBag}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'points'}}\n @opacity={{this.fadePoints}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'plot'}}\n @opacity={{this.fadePlot}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'blank'}}\n @opacity={{this.fadeBlank}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'game'}}\n @opacity={{this.fadeGame}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n </c.Parallel>\n </c.Sequence>\n </Choreo>\n\n {{! the notches are pointer shortcuts to a stop, out of the tab\n order; the keyboard moves the rail with its arrow keys }}\n <div\n {{! template-lint-disable no-nested-interactive require-presentational-children }}\n class='rk-rail'\n role='slider'\n tabindex='0'\n aria-label='Arrangement'\n aria-valuemin='0'\n aria-valuemax={{LAST}}\n aria-valuenow='0'\n aria-valuetext={{STOP_ZERO}}\n {{this.railed}}\n {{on 'keydown' this.key}}\n {{on 'pointerdown' this.grab}}\n {{on 'pointermove' this.move}}\n {{on 'pointerup' this.land}}\n {{on 'pointercancel' this.land}}\n >\n <span class='rk-track'></span>\n {{#each STOPS as |stop i|}}\n <button\n type='button'\n class='rk-notch'\n tabindex='-1'\n data-notch={{i}}\n style={{notch i}}\n {{on 'click' this.pick}}\n ><span>{{stop}}</span></button>\n {{/each}}\n <span class='rk-thumb' {{this.thumbed}}></span>\n </div>\n\n </div></div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n /* THE BOX THE SCALED CONTROL OCCUPIES.\n *\n * `transform` does not change a layout box, so a scaled control still claims\n * its full unscaled height and paints over whatever follows — which is how\n * the rack ended up printed across the card's own title. The wrapper carries\n * the scaled size as real layout; the control inside it is what gets scaled.\n *\n * `--k` comes from container queries below rather than from a\n * ResizeObserver. Every seat here is arithmetic in a fixed 340-wide space, so\n * the control cannot reflow — but which of a handful of sizes it should be is\n * a question about the space it was given, and that is exactly what a\n * container query answers. No observer, no imperative write, and it settles\n * in the same pass as the layout it depends on. */\n .rk-fit-box {\n width: calc(340px * var(--k, 1));\n height: calc(376px * var(--k, 1));\n }\n\n @container (min-width: 430px) {\n .stage-wrap .rk-fit-box {\n /* Room is not a reason to be bigger.\n *\n * At 1.5 the control simply grew until it hit the walls, which makes a\n * 22px tile into a 33px one and reads as a zoom rather than as a demo\n * with space around it. A modest step up and the rest spent on margin is\n * the better trade: the tiles stay the size they were designed at, and\n * the thing gets room to sit in. */\n --k: 1.18;\n }\n }\n @container (max-width: 339px) {\n .rk-fit-box {\n --k: 0.96;\n }\n }\n\n @container (max-width: 325px) {\n .rk-fit-box {\n --k: 0.92;\n }\n }\n\n @container (max-width: 312px) {\n .rk-fit-box {\n --k: 0.88;\n }\n }\n\n @container (max-width: 298px) {\n .rk-fit-box {\n --k: 0.84;\n }\n }\n\n @container (max-width: 285px) {\n .rk-fit-box {\n --k: 0.78;\n }\n }\n\n @container (max-width: 264px) {\n .rk-fit-box {\n --k: 0.7;\n }\n }\n\n /* Whatever is left over after the control has taken its size becomes margin,\n evenly, rather than being absorbed by making everything larger. */\n .ex:has(.rk-fit-box) {\n display: grid;\n place-items: center;\n padding-block: 16px;\n }\n\n /* One control, scaled as a whole.\n *\n * Every seat in this demo is arithmetic in a fixed 340-wide space — which is\n * what lets the journey be keyframes rather than measurements, and also means\n * it cannot reflow. In the gallery grid and on a phone it was simply cropped,\n * and what got cropped was the rack: the demo lost the one thing you touch.\n * Scaling the whole thing keeps every proportion and every hit target in\n * agreement with the arithmetic. */\n .rk-fit {\n width: 340px;\n height: 376px;\n transform: scale(var(--k, 1));\n transform-origin: top left;\n }\n\n .ex:has(.rk-stage) {\n container-type: inline-size;\n --tile: #f0e3c2;\n --tile-lip: rgba(122, 92, 44, 0.42);\n --tile-top: rgba(255, 253, 244, 0.85);\n --tile-ink: #3a2b16;\n }\n\n .rk-stage {\n position: relative;\n width: 340px;\n height: 292px;\n }\n\n /* A TILE, as an object.\n *\n * Two inset shadows do the whole job: a light line along the top edge and a\n * darker one along the bottom, which is what a chamfer looks like from\n * straight on. The letter is a display face rather than the mono the rest of\n * the page uses — a Scrabble tile is printed, not typeset. */\n .rk-l {\n position: absolute;\n left: 0;\n top: 0;\n width: 22px;\n height: 22px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 5px;\n background:\n radial-gradient(\n 120% 120% at 30% 20%,\n rgba(255, 255, 255, 0.55),\n rgba(255, 255, 255, 0) 60%\n ),\n var(--tile);\n color: var(--tile-ink);\n box-shadow:\n inset 0 -2.5px 0 var(--tile-lip),\n inset 0 1.5px 0 var(--tile-top),\n inset 2px 0 0 rgba(255, 253, 244, 0.35),\n inset -2px 0 0 rgba(122, 92, 44, 0.18),\n 0 2px 3px rgba(var(--shadow-rgb), calc(0.55 * var(--shadow-a))),\n 0 -1px 2px rgba(var(--shadow-rgb), calc(0.3 * var(--shadow-a)));\n font-family: var(--font-display);\n font-size: 13px;\n line-height: 1;\n }\n\n .rk-l b {\n font-weight: 700;\n text-transform: uppercase;\n /* optically centred: the value in the corner pulls the eye down-right */\n transform: translate(-1px, -1.5px);\n }\n\n .rk-l i {\n position: absolute;\n right: 2px;\n bottom: 1px;\n font-family: var(--font-mono);\n font-style: normal;\n font-size: 7px;\n font-weight: 700;\n opacity: 0.6;\n }\n\n /* vowels are marked in EVERY arrangement, not only the one that groups them:\n a colour that arrived on a pose change would have nothing to fade with */\n .rk-l.is-vowel {\n --tile: #f6d7a2;\n color: #6b3a10;\n }\n\n /* a blank has no letter and no value — it is the absence, drawn */\n .rk-l.is-blank {\n --tile: #e7dcc4;\n }\n\n /* ---- the furniture, each piece true in one arrangement ---- */\n\n .rk-axis {\n position: absolute;\n background: var(--line);\n opacity: 0;\n }\n\n .rk-axis.is-x {\n left: 12px;\n right: 6px;\n top: 262px;\n height: 1px;\n }\n\n .rk-axis.is-y {\n left: 12px;\n top: 10px;\n bottom: 36px;\n width: 1px;\n }\n\n .rk-alab,\n .rk-tick {\n position: absolute;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-faint);\n opacity: 0;\n white-space: nowrap;\n }\n\n .rk-alab.is-x {\n right: 8px;\n top: 276px;\n }\n\n .rk-alab.is-yt {\n left: 20px;\n top: 10px;\n }\n\n .rk-alab.is-yb {\n left: 20px;\n bottom: 40px;\n }\n\n .rk-tick {\n top: 266px;\n transform: translateX(-50%);\n font-size: 9px;\n color: var(--ink-dim);\n }\n\n /* The board: fifteen-by-fifteen is wider than this stage, so it is thirteen\n squares — enough for the longest word with room around it. Drawn with\n gradients rather than 169 elements, because it is scenery: nothing on it\n moves, and the tiles that land on it are the participants. */\n .rk-grid {\n position: absolute;\n left: 33px;\n top: 8px;\n width: 242px;\n height: 242px;\n border-radius: 4px;\n background-color: #e6ddcd;\n background-image:\n linear-gradient(\n to right,\n rgba(120, 96, 60, 0.22) 1px,\n transparent 1px\n ),\n linear-gradient(\n to bottom,\n rgba(120, 96, 60, 0.22) 1px,\n transparent 1px\n );\n background-size: 22px 22px;\n box-shadow:\n inset 0 0 0 1px rgba(120, 96, 60, 0.35),\n 0 2px 8px rgba(var(--shadow-rgb), calc(0.35 * var(--shadow-a)));\n opacity: 0;\n }\n\n .choreo-site:not([data-theme='light']) .rk-grid {\n background-color: #3a332b;\n background-image:\n linear-gradient(\n to right,\n rgba(226, 205, 170, 0.16) 1px,\n transparent 1px\n ),\n linear-gradient(\n to bottom,\n rgba(226, 205, 170, 0.16) 1px,\n transparent 1px\n );\n }\n\n .rk-alab.is-game {\n left: 12px;\n top: 258px;\n text-transform: none;\n letter-spacing: 0.04em;\n font-size: 9px;\n color: var(--ink-dim);\n }\n\n .rk-key {\n position: absolute;\n top: 232px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-dim);\n opacity: 0;\n white-space: nowrap;\n }\n\n .rk-key::before {\n content: '';\n width: 13px;\n height: 13px;\n border-radius: 3px;\n background: var(--tile);\n box-shadow: inset 0 -2px 0 var(--tile-lip);\n }\n\n .rk-key.is-v {\n left: 40px;\n }\n\n .rk-key.is-v::before {\n background: #f6d7a2;\n }\n\n .rk-key.is-c {\n left: 150px;\n }\n\n /* ---- the rack: a slider dressed as the thing it is ---- */\n\n .rk-rail {\n --pad: 15px;\n position: relative;\n width: 340px;\n height: 28px;\n margin-top: 8px;\n padding: 0 var(--pad);\n border-radius: 6px 6px 9px 9px;\n background: linear-gradient(180deg, #8a6236, #6d4a26 62%, #5b3d1f);\n box-shadow:\n inset 0 2px 0 rgba(255, 226, 187, 0.32),\n inset 0 -3px 0 rgba(0, 0, 0, 0.28),\n 0 3px 6px rgba(var(--shadow-rgb), calc(0.6 * var(--shadow-a)));\n touch-action: none;\n cursor: grab;\n }\n\n .rk-rail:active {\n cursor: grabbing;\n }\n\n .rk-rail:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 3px;\n }\n\n /* the groove the marker slides in, filled up to where the playhead is */\n .rk-track {\n position: absolute;\n left: var(--pad);\n right: var(--pad);\n top: 13px;\n height: 5px;\n border-radius: 999px;\n background: linear-gradient(\n 90deg,\n rgba(255, 226, 187, 0.55) var(--at, 0%),\n rgba(0, 0, 0, 0.3) var(--at, 0%)\n );\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);\n }\n /* Positioned inside the TRACK's span, not the rack's.\n `--f` is a plain fraction the component writes each frame; the arithmetic\n that turns it into a place belongs here, where the padding is known. */\n .rk-thumb {\n position: absolute;\n top: 3px;\n left: calc(var(--pad) + (100% - 2 * var(--pad)) * var(--f, 0));\n transform: translateX(-50%);\n width: 22px;\n height: 22px;\n border-radius: 4px;\n background: var(--tile);\n box-shadow:\n inset 0 -2px 0 var(--tile-lip),\n inset 0 1.5px 0 var(--tile-top),\n 0 2px 4px rgba(0, 0, 0, 0.45);\n pointer-events: none;\n }\n\n /* the stops sit under the rack, and the one in force says so */\n .rk-notch {\n position: absolute;\n top: 34px;\n left: calc(var(--pad) + (100% - 2 * var(--pad)) * var(--f, 0));\n transform: translateX(-50%);\n padding: 3px 6px;\n border: 0;\n border-radius: 5px;\n background: none;\n cursor: pointer;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-faint);\n white-space: nowrap;\n transition:\n color 0.18s ease,\n background 0.18s ease,\n font-size 0.18s ease;\n }\n\n /* The end labels are centred on the ends of the TRACK, which leaves them\n floating a padding's width inside the rack rather than agreeing with it.\n The first is flush with the rack's left edge and the last with its right,\n so the row of stops spans exactly what the rack spans — and neither can\n hang off a narrow card. */\n .rk-notch:first-of-type {\n left: 0;\n transform: translateX(0);\n }\n\n .rk-notch:last-of-type {\n left: 100%;\n transform: translateX(-100%);\n }\n\n @media (hover: hover) {\n .rk-notch:hover {\n color: var(--ink-dim);\n }\n }\n\n .rk-notch.is-on {\n font-size: 11px;\n font-weight: 700;\n color: var(--tile-ink);\n background: var(--tile);\n box-shadow: inset 0 -2px 0 var(--tile-lip);\n }\n\n .rk-wire {\n display: none;\n }\n\n /* a Scrabble tile is a physical object, and the one thing it is not is dark */\n .choreo-site:not([data-theme='light']) .rk-l,\n .choreo-site:not([data-theme='light']) .rk-thumb {\n --tile-lip: rgba(92, 68, 28, 0.55);\n }\n\n .choreo-site:not([data-theme='light']) .rk-notch.is-on {\n color: #3a2b16;\n }\n @container (max-height: 430px) {\n .rk-fit-box {\n transform: scale(0.95);\n transform-origin: center;\n }\n }\n </style>\n </template>\n}\n\n/**\n * A tick sits under the middle of the column its value owns.\n *\n * The bag's counts are evenly spaced and the points are logarithmic, so the\n * two axes are not the same ruler — which is exactly why they fade in and out\n * with their own arrangements rather than sharing one row of labels.\n */\nfunction tickAt(value: number, scale: number[]) {\n const lanes = scale === values ? PTS_LANES : BAG_LANES;\n return htmlSafe(`left: ${lanes.centres[scale.indexOf(value)]!}px`);\n}\n\nfunction tickId(axis: string, value: number) {\n return `tick-${axis}-${value}`;\n}\n\nfunction notch(i: number) {\n return htmlSafe(`--f: ${i / LAST}`);\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSeconds('rack', SPAN, 'SPAN duration');\n\nexport class RackDemo extends GalleryDemo {\n static stage = Rack;\n static notes = RackNotes;\n}\n";
516
+ var rack_default = "import type { ChoreoRun, Sprite } from '@cardstack/choreo';\nimport { Choreo } from '@cardstack/choreo';\nimport { array } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { htmlSafe } from '@ember/template';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { afterSettle, motion } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneSeconds } from '../lib/tuning';\nimport RackNotes from '../notes/rack';\n\n/**\n * The four arrangements. The index IS the slider's whole number.\n *\n * 0 `alpha` A to Z, reading order\n * 1 `groups` the five vowels lifted out of the twenty-one consonants\n * 2 `bag` stacked by how many of that tile a Scrabble set contains\n * 3 `points` stacked by what the tile is worth\n * 4 `plot` frequency against points, with axes\n * 5 `game` the whole set played out on a board\n *\n * `points` and `plot` share an x axis on purpose, so the last leg is a purely\n * vertical spread out of the stacks. `bag` comes before them rather than\n * after for the same reason: it has an x axis of its own, and putting it\n * between the two would break the one they share.\n */\nconst STOPS = ['A–Z', 'Vowels', 'Bag', 'Points', 'Plot', 'Game'];\nconst LAST = STOPS.length - 1;\nconst STOP_ZERO = STOPS[0];\n\n/**\n * ONE SCORE, NOT THREE.\n *\n * A changeset has exactly two ends, so a journey through four poses looks at\n * first like three scores with the host swapping between them at each notch.\n * That is how the first two versions of this demo worked and it is a bad\n * trade: every swap needs a render round-trip, the swap happens at a notch\n * while the outgoing run is still holding its sprites, and a gesture can\n * outrun the whole arrangement.\n *\n * None of it is necessary, because a keyframe array is the from-and-to AND\n * ANY WAYPOINTS in one value. Four stops per property is one score for the\n * whole journey. The DOM never changes: no pose classes, no re-render, no\n * changeset to depend on — the region compiles once on mount and the slider\n * scrubs its clock from end to end.\n *\n * The prices are worth naming. The poses have to be arithmetic rather than\n * stylesheet, since nothing can be measured out of a layout that is never\n * rendered. And with the tiles' geometry stated rather than measured, the\n * whole \"positioned against its own size\" family cannot arise: every tile is\n * the same 28px square in all four arrangements and only its transform moves.\n */\nconst SPAN = 1.8;\nconst LINEAR = 'linear' as const;\nconst HOME = { damping: 24, stiffness: 240 } as const;\n\n/** relative frequency of each letter in English text, in percent */\nconst FREQ: Record<string, number> = {\n a: 8.17,\n b: 1.29,\n c: 2.78,\n d: 4.25,\n e: 12.7,\n f: 2.23,\n g: 2.02,\n h: 6.09,\n i: 6.97,\n j: 0.15,\n k: 0.77,\n l: 4.03,\n m: 2.41,\n n: 6.75,\n o: 7.51,\n p: 1.93,\n q: 0.1,\n r: 5.99,\n s: 6.33,\n t: 9.06,\n u: 2.76,\n v: 0.98,\n w: 2.36,\n x: 0.15,\n y: 1.97,\n z: 0.07,\n};\n\n/** what the tile is worth in Scrabble */\nconst POINTS: Record<string, number> = {\n a: 1,\n b: 3,\n c: 3,\n d: 2,\n e: 1,\n f: 4,\n g: 2,\n h: 4,\n i: 1,\n j: 8,\n k: 5,\n l: 1,\n m: 3,\n n: 1,\n o: 1,\n p: 3,\n q: 10,\n r: 1,\n s: 1,\n t: 1,\n u: 1,\n v: 4,\n w: 4,\n x: 8,\n y: 4,\n z: 10,\n};\n\nconst VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);\n\n/**\n * The blank.\n *\n * A set holds two, but one on the board says the same thing and a pair of\n * identical featureless tiles reads as a mistake rather than as a fact. It is\n * worth nothing and stands for anything. They belong in three of the five\n * arrangements and genuinely do not belong in the fourth: a blank has no\n * letter, so it has no letter frequency, and putting it anywhere on that\n * chart would be inventing a number.\n *\n * So it leaves. Its opacity keyframes run 1,1,1,1,0 and it fades out over the\n * final leg while everything else spreads — which says what the footnote\n * under a chart would have had to say, without a footnote.\n */\nconst BLANKS = ['blank'];\n\n/** how many of each tile a Scrabble set contains */\nconst BAG: Record<string, number> = {\n a: 9,\n b: 2,\n c: 2,\n d: 4,\n e: 12,\n f: 2,\n g: 3,\n h: 2,\n i: 9,\n j: 1,\n k: 1,\n l: 4,\n m: 2,\n n: 6,\n o: 8,\n p: 2,\n q: 1,\n r: 6,\n s: 4,\n t: 6,\n u: 4,\n v: 2,\n w: 2,\n x: 1,\n y: 2,\n z: 1,\n};\n\n/**\n * CWM FJORD BANK GLYPHS VEXT QUIZ.\n *\n * A perfect pangram: six words, twenty-six letters, not one of them twice.\n * (A cwm is a steep hollow on a hillside; vext is the archaic spelling of\n * vexed. Both are good in Scrabble.) Nothing crosses — they are laid out as\n * separate blocks — which is what makes it placeable at all: an interlocking\n * version of the same idea is not, and I have the exhaustive search to prove\n * it. Every letter has to be used exactly once, so VET and BAN would each\n * need BOTH their repeated letters already adjacent on the board, and no\n * arrangement of those words provides it.\n *\n * The two blanks are not played. They sit in the corner, which is the honest\n * place for a tile nobody needed.\n */\nconst GAME: [string, number, number][] = [\n // word, column, row — staggered, because a board someone has played on is\n // not six centred rows. Centring them was worse than it sounds: an\n // even-length word cannot be centred on an odd grid without sitting half a\n // square off, so four of the six looked misaligned rather than placed.\n ['CWM', 1, 0],\n ['FJORD', 5, 2],\n ['BANK', 0, 4],\n ['GLYPHS', 4, 6],\n ['VEXT', 1, 8],\n ['QUIZ', 6, 10],\n];\nconst GRID = 11;\n\n/* ── the layouts, arithmetic rather than stylesheet ─────────────────────── */\n\nconst TILE = 22;\nconst STEP = 27;\nconst COLS = 7;\nconst BOARD = { h: 292, w: 340 };\n/** the board's own pitch — one square per cell, a tile centred in each */\nconst CELL = 22;\nconst BOARD_X = Math.round((BOARD.w - GRID * CELL) / 2);\nconst BOARD_Y = 8;\nconst square = (col: number, row: number) => ({\n x: BOARD_X + col * CELL + (CELL - TILE) / 2,\n y: BOARD_Y + row * CELL + (CELL - TILE) / 2,\n});\nconst GRID_X = Math.round((BOARD.w - (COLS - 1) * STEP - TILE) / 2);\n\nconst alphabet = Object.keys(FREQ);\n/** the full set: twenty-six letters and a blank */\nconst bagful = [...alphabet, ...BLANKS];\nconst isBlank = (ch: string) => ch.startsWith('blank');\nconst topFreq = Math.max(...Object.values(FREQ));\nfor (const b of BLANKS) {\n POINTS[b] = 0;\n BAG[b] = 2;\n}\n/**\n * Both axes run the WHOLE range, including the values nothing has.\n *\n * Listing only the values that occur — 1, 2, 3, 4, 6, 8, 9, 12 — spaces them\n * evenly and quietly lies: it makes the step from 4 to 6 look like the step\n * from 1 to 2, and hides that there is no letter you hold six of. An ordinal\n * axis with empty columns says the true shape, and the gaps are part of it.\n */\nconst range = (lo: number, hi: number) =>\n Array.from({ length: hi - lo + 1 }, (_, i) => lo + i);\n/** points start at ZERO, because the blanks are worth nothing and are real */\nconst values = range(0, Math.max(...Object.values(POINTS)));\nconst counts = range(1, Math.max(...Object.values(BAG)));\n\nconst cell = (n: number, x0: number, y0: number) => ({\n x: x0 + (n % COLS) * STEP,\n y: y0 + Math.floor(n / COLS) * STEP,\n});\n\n/**\n * Two arrangements share one x axis, and that is the point.\n *\n * `points` stacks the tiles into a column per value — a distribution you can\n * read at a glance: ten letters are worth 1, two are worth 10. `plot` keeps\n * every tile in the same column and lets it find its true height. So the last\n * leg of the journey is a purely VERTICAL spread out of the stacks, which is\n * the clearest way to show what the chart is actually claiming: how often a\n * letter turns up, against what it is worth.\n */\nconst PLOT = { bottom: 256, left: 16, right: 332, top: 14 };\n\n/** every letter of a given value, most frequent first */\nconst strip = (value: number) =>\n bagful\n .filter((c) => POINTS[c] === value)\n .sort((a, b) => (FREQ[b] ?? -1) - (FREQ[a] ?? -1));\n\n/**\n * The points axis gives each column the width its CROWD needs.\n *\n * A tile can be worth 1, 2, 3, 4, 5, 8 or 10, and the letters are not spread\n * evenly across those: ten are worth 1, and only one is worth 5. Spaced\n * evenly, ten tiles fight over a seventh of the board while a single K gets\n * the same room — and no amount of lane-shuffling fixes it, because ten 28px\n * tiles do not fit in 35px however they are arranged. A log axis was the\n * first attempt and it made the far end worse: it squeezed 8 and 10 to 25px\n * apart, and J and X, which have exactly the same frequency, had nowhere to\n * stand but on top of each other.\n *\n * So the axis is ordinal and each value's slot is proportional to how many\n * letters it holds, with a floor so a lone K still gets a place to stand.\n * The ticks are labelled, so the axis stays readable without being linear.\n */\nconst SPAN_X = PLOT.right - PLOT.left;\n\n/**\n * Columns are EVENLY spaced, and the crowding is absorbed by depth instead.\n *\n * Widths proportional to population were tried and read as a broken ruler:\n * the gap from 1 to 2 was four times the gap from 8 to 10, so the axis\n * implied a scale it did not have. An even axis is honest and legible, and\n * where a column holds more than it can stack the pile simply wraps into a\n * second file — the crowd shows as depth rather than as width.\n */\nfunction lanesOf(n: number) {\n const width = SPAN_X / n;\n return {\n centres: Array.from({ length: n }, (_, i) => PLOT.left + (i + 0.5) * width),\n width,\n };\n}\n\nconst PTS_LANES = lanesOf(values.length);\nconst COLUMN = (value: number) =>\n PTS_LANES.centres[values.indexOf(value)]! - TILE / 2;\n\n/**\n * A stack's row pitch, which CLEARS the tile.\n *\n * Overlapping piles were tried and read as a mess rather than as a stack: a\n * tile half-covering the one below hides the value in its corner, which is\n * the number the arrangement is about. Nothing overlaps now, which sets the\n * board's height rather than the other way round — eleven tiles is the\n * deepest column there is, and the board is tall enough for eleven.\n */\nconst STACK = TILE + 3;\n\n/** where the k-th tile of a pile sits, counting up from the baseline */\nconst pile = (x0: number, k: number) => ({\n x: x0,\n y: PLOT.bottom - TILE - k * STACK,\n});\n\nconst stacked = new Map<string, { x: number; y: number }>();\nconst bagged = new Map<string, { x: number; y: number }>();\nconst BAG_LANES = lanesOf(counts.length);\nfor (const count of counts) {\n const x = BAG_LANES.centres[counts.indexOf(count)]! - TILE / 2;\n const members = bagful.filter((c) => BAG[c] === count);\n members.forEach((ch, k) => {\n bagged.set(ch, pile(x, k));\n });\n}\nconst plotted = new Map<string, { x: number; y: number }>();\n\n/**\n * The frequency axis runs DOWNWARD — the most common letters lie along the\n * bottom, the rare ones climb away from it — and it is a SQUARE-ROOT scale.\n *\n * English is lopsided: E alone is 12.7% and half the alphabet is under 2%, so\n * on a linear axis twenty letters pile into one eighth of the board. Rooting\n * the ratio pulls that crowd apart without reordering anybody.\n *\n * Collisions are then resolved by SEARCH rather than by a fixed set of lanes,\n * because the crowding is not uniform: one column needs three lanes and the\n * next needs none. Each tile takes its true height and the first free spot\n * near it, preferring sideways movement — height is the variable being read,\n * so it is the one that must stay honest — and giving ground vertically only\n * when a column has no width left. J and X, which have identical frequency,\n * are the case that forces the last resort to exist.\n */\nconst placed: { x: number; y: number }[] = [];\nconst clear = (x: number, y: number) =>\n placed.every(\n (q) => Math.abs(q.x - x) >= TILE - 2 || Math.abs(q.y - y) >= TILE - 2,\n );\n\nfor (const value of values) {\n const x0 = COLUMN(value);\n const members = strip(value);\n // stacked: most frequent on the bottom row, matching where the plot will\n // put it, so nothing has to cross on the way out\n members.forEach((ch, k) => {\n stacked.set(ch, pile(x0, k));\n });\n\n for (const ch of members) {\n // A BLANK IS THE OUTLIER, not an omission.\n //\n // It has no letter, so it has no letter frequency — but it can stand for\n // any letter, which means it plays as often as the commonest one does.\n // Worth nothing and worth everything: zero points, and as useful as E.\n // Dropping it off the chart would have been the tidier lie.\n const y0 = isBlank(ch)\n ? PLOT.top + (PLOT.bottom - PLOT.top) - TILE / 2\n : PLOT.top +\n Math.sqrt(FREQ[ch]! / topFreq) * (PLOT.bottom - PLOT.top) -\n TILE / 2;\n // Widen the search until something is clear.\n //\n // Sideways first and as far as a whole tile, because height is the\n // variable being read and must stay honest for as long as possible; then,\n // and only then, small vertical give. A column is narrower than a tile\n // here, so a fan that stayed inside its own column could not separate\n // anybody — the collision test is what keeps neighbours apart, not the\n // column's width.\n let spot = { x: x0, y: y0 };\n search: for (const dy of [0, -9, 9, -18, 18, -27, 27]) {\n for (const dx of [0, -0.45, 0.45, -0.9, 0.9, -1.35, 1.35]) {\n const x = x0 + dx * TILE;\n if (x >= PLOT.left - TILE && x <= PLOT.right && clear(x, y0 + dy)) {\n spot = { x, y: y0 + dy };\n break search;\n }\n }\n }\n placed.push(spot);\n plotted.set(ch, spot);\n }\n}\n\n/** every letter's square, worked out from the six words */\nconst played = new Map<string, { x: number; y: number }>();\nfor (const [word, col, row] of GAME) {\n [...word].forEach((ch, j) => {\n played.set(ch.toLowerCase(), square(col + j, row));\n });\n}\n// the tile nobody played, in the corner\nBLANKS.forEach((b, k) => played.set(b, square(GRID - 1 - k, GRID - 1)));\n\n/**\n * The top row of the `groups` arrangement: the vowels, and then the blanks.\n *\n * A blank is neither a vowel nor a consonant, and giving it a line of its own\n * under the consonant block put it where nothing else was — which read as a\n * mistake and, worse, put the two of them on the same square. They belong at\n * the END of the vowels: the tiles that can be any vowel you like, after the\n * five that are.\n */\nconst TOP_ROW = [...alphabet.filter((c) => VOWELS.has(c)), ...BLANKS];\n\n/** centre a row of `n` tiles on the board */\nconst row = (rank: number, n: number, y: number) => ({\n x: Math.round((BOARD.w - (n - 1) * STEP - TILE) / 2) + rank * STEP,\n y,\n});\n\nconst letters = bagful.map((ch, i) => {\n const blank = isBlank(ch);\n const vowel = VOWELS.has(ch);\n const top = TOP_ROW.indexOf(ch);\n const rank =\n top >= 0 ? top : bagful.filter((c) => TOP_ROW.indexOf(c) < 0).indexOf(ch);\n const sack = bagged.get(ch)!;\n const pile = stacked.get(ch)!;\n const spot = plotted.get(ch)!;\n return {\n blank,\n ch: blank ? '' : ch,\n key: ch,\n share: FREQ[ch] ?? 0,\n vowel,\n worth: POINTS[ch]!,\n /** the tile's seat in each of the five arrangements, in order */\n seats: [\n cell(i, GRID_X, 40),\n top >= 0 ? row(rank, TOP_ROW.length, 26) : cell(rank, GRID_X, 96),\n sack,\n pile,\n spot,\n played.get(ch)!,\n ],\n };\n});\n\nconst seats = new Map(letters.map((l) => [l.key, l.seats]));\n\nconst clamp = (v: number, lo: number, hi: number) =>\n v < lo ? lo : v > hi ? hi : v;\n\n/**\n * A detent under the finger: the playhead is drawn toward whichever stop it\n * is nearest, hardest when it is nearly there.\n *\n * Not a snap — the drag stays continuous and every value in between is still\n * reachable — but a stop becomes slightly sticky, so letting go anywhere near\n * one leaves the arrangement dead centre rather than a few percent past it. A\n * rack with notches in it should feel like it has notches in it.\n */\nconst detent = (p: number) => {\n const near = Math.round(p);\n const off = p - near;\n // pull only inside a quarter of a stop, and taper to nothing at the edge\n const reach = 0.25;\n if (Math.abs(off) >= reach) {\n return p;\n }\n const pull = 1 - Math.abs(off) / reach;\n return p - off * pull * 0.55;\n};\n\n/** easeInOutQuint — still, then quick, then a long settle */\nconst quint = (t: number) =>\n t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2;\n\n/**\n * A slider IS a playhead, so this one drives a score directly.\n *\n * `/sheet` is the other half of the idea. There, `drag=\"y\"` owns the sheet's\n * position while the finger is down and the tiles do not move at all until\n * release, when a class change and `layout=true` spring them into shape.\n * Measure it mid-drag and the tile is the same width the whole way. That is\n * not a bug: `layout=true` is measure-and-fire, and there is nowhere to hand\n * it a progress. A Choreo run has one, because a run is a value with a clock\n * on it.\n *\n * WHY LETTERS. A morph is only worth scrubbing if the eye can follow it, and\n * that needs the things being moved to be individually nameable. Twenty-six\n * identical cards flying between a list and a grid is noise however exact the\n * arithmetic — every item crosses every other and none of them is anyone in\n * particular. Twenty-six lettered tiles crossing is a sort you can read: E\n * leaves the middle of the alphabet, keeps its seat when the vowels gather,\n * and ends up alone at the top of the plot, and you can watch it go.\n */\nexport class Rack extends Component {\n /**\n * THE PARKED OPENING.\n *\n * A region compiles a score from a PASS, and a pass is a render that\n * changes something inside it. This demo's DOM never changes — that is the\n * whole point of putting the journey in keyframes — so left alone the\n * region has nothing to react to and no run is ever built. One bump of a\n * counter after mount is enough: it changes nothing anyone can see, the\n * region measures a changeset, the score compiles, and the gate parks it at\n * zero before a frame of it can play. From then on the slider owns the\n * clock and nothing renders again.\n */\n @tracked private take = 0;\n\n /** the region's context. Not tracked — read from pointer handlers */\n private c?: { run: ChoreoRun | null };\n /** the slider, in notches */\n private p = 0;\n /** the stop the control is heading for — what a repeated key steps from */\n private goal = 0;\n private rail?: HTMLElement;\n private thumb?: HTMLElement;\n private dragging = false;\n private raf = 0;\n private v = 0;\n\n private get run() {\n return this.c?.run ?? null;\n }\n\n /**\n * Park the clock where the slider says.\n *\n * The clamp at the top is not cosmetic. A run allowed to reach its own\n * duration is FINISHED, and a finished run replays itself on any later\n * render — so a scrubbable run is parked a hair short of the end and never\n * allowed to retire.\n */\n private draw() {\n this.paint();\n const run = this.run;\n if (!run) {\n return;\n }\n // the head gate held the clock at zero; open it once, or every seek\n // parks right back on it\n if (run.parked) {\n run.advance();\n }\n run.pause();\n run.time = (this.p / LAST) * Math.max(0, run.duration - 0.001);\n }\n\n /**\n * The thumb, the filled track and the active label — all written straight\n * from the playhead, none of them measured, none of them tracked.\n *\n * Marking the nearest stop is the sort of thing that wants a `@tracked`\n * index, and it must not have one: the rail lives outside the region but\n * the component does not, so a re-render would reach the <Choreo> block and\n * a render inside a region is a PASS. A class toggle costs nothing and\n * cannot recompile a score.\n */\n private paint() {\n const f = this.p / LAST;\n // a fraction, not a percentage: the stylesheet turns it into a position\n // inside the track's span rather than the rack's\n this.thumb?.style.setProperty('--f', `${f}`);\n this.rail?.style.setProperty('--at', `${f * 100}%`);\n const near = Math.round(this.p);\n if (near !== this.lit) {\n this.lit = near;\n this.rail?.setAttribute('aria-valuenow', `${near}`);\n this.rail?.setAttribute('aria-valuetext', STOPS[near] ?? '');\n this.rail\n ?.querySelectorAll<HTMLElement>('.rk-notch')\n .forEach((el, i) => el.classList.toggle('is-on', i === near));\n }\n }\n\n /** which stop is currently marked, so the class is only rewritten on change */\n private lit = -1;\n\n /**\n * Hold the run the moment it exists — which is later than you would think.\n *\n * Modifiers on the region's CHILDREN run before the region's own update, so\n * when this fires `c.run` is still the previous pass's run. `afterSettle`\n * is the engine's post-render microtask: after the score exists, still\n * before paint.\n */\n wire = modifier((_el: Element, [c]: [{ run: ChoreoRun | null }]) => {\n this.c = c;\n this.draw();\n afterSettle(() => this.draw());\n });\n\n private stop() {\n cancelAnimationFrame(this.raf);\n this.raf = 0;\n }\n\n /**\n * Where a pointer at `clientX` puts the playhead.\n *\n * Measured against the TRACK, not the rack around it. The rack is padded,\n * so a fraction of its width lands short at one end and past the other —\n * which is exactly how the marker ended up sitting off the end of the wood\n * at the last stop.\n */\n private at(clientX: number) {\n const track = this.rail?.querySelector('.rk-track');\n if (!track) {\n return this.p;\n }\n const box = track.getBoundingClientRect();\n return clamp(((clientX - box.x) / box.width) * LAST, 0, LAST);\n }\n\n grab = (untyped: Event) => {\n // the realm's `{{on}}` types every listener's argument as a plain Event\n const event = untyped as PointerEvent;\n if (event.button !== 0) {\n return;\n }\n // A STOP IS NOT THE TRACK.\n //\n // The labels are children of the rack, so a press on one arrives here\n // first and would seize the playhead to wherever that label sits — the\n // ride is then over before `pick` gets to ask for it, and what you see is\n // a jump with an ease politely appended. A press on a stop belongs to the\n // stop; the rack only owns presses on itself.\n if ((event.target as Element | null)?.closest('.rk-notch')) {\n return;\n }\n this.stop();\n this.v = 0;\n this.dragging = true;\n (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);\n this.p = this.at(event.clientX);\n this.draw();\n };\n\n move = (untyped: Event) => {\n const event = untyped as PointerEvent;\n if (!this.dragging) {\n return;\n }\n const next = detent(this.at(event.clientX));\n this.v = (next - this.p) * 60;\n this.p = next;\n this.draw();\n };\n\n /**\n * The thumb lets go, and a spring takes the clock.\n *\n * `/sheet` hands a POSITION to a spring on release, and picks its detent\n * from where the throw was going rather than where the finger stopped. Same\n * rule; the value the spring settles is the playhead, and a notch is a\n * whole number on it.\n */\n land = (untyped: Event) => {\n const event = untyped as PointerEvent;\n if (!this.dragging) {\n return;\n }\n this.dragging = false;\n (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);\n const landing = Math.round(clamp(this.p + this.v * 0.12, 0, LAST));\n this.goal = landing;\n this.springTo(landing);\n };\n\n /**\n * Aim at a stop.\n *\n * From REST it is an eased ride: a tap or a key press has no momentum to\n * spend, so it gets a curve instead. Mid-RIDE it hands over to the spring\n * with whatever speed it already had — pressing an arrow twice quickly\n * should read as one longer journey, and restarting the ease each time\n * made it stutter, decelerating into a stop it never reached before\n * setting off again.\n */\n private aim(target: number) {\n if (this.raf) {\n this.goal = target;\n this.springTo(target);\n return;\n }\n this.easeTo(target);\n }\n\n /**\n * A stop, tapped: an eased ride rather than a spring.\n *\n * A throw has momentum and a spring is the honest way to spend it. A tap\n * has none — there is nothing to carry — so it gets a curve instead, and\n * quint is the one that reads as deliberate at this distance: a still\n * start, a quick middle, a long settle. The knob is not animated\n * separately; it is drawn from the playhead every frame, so it rides the\n * same curve as the tiles by construction.\n */\n private easeTo(target: number) {\n this.stop();\n this.goal = target;\n const from = this.p;\n const span = Math.abs(target - from);\n if (span < 0.001) {\n return;\n }\n // longer for a longer journey, but not proportionally — crossing four\n // stops should not take four times as long as crossing one\n const ms = 380 + Math.sqrt(span) * 300;\n const started = performance.now();\n let last = started;\n let lastP = from;\n const tick = (now: number) => {\n const t = Math.min(1, (now - started) / ms);\n this.p = from + (target - from) * quint(t);\n // keep a real velocity: an ease that reports zero has nothing to hand\n // over, and a second key press mid-ride then restarts from a dead stop\n const dt = Math.max(1, now - last) / 1000;\n this.v = (this.p - lastP) / dt;\n last = now;\n lastP = this.p;\n this.draw();\n if (t >= 1) {\n this.p = target;\n this.v = 0;\n this.draw();\n this.raf = 0;\n return;\n }\n this.raf = requestAnimationFrame(tick);\n };\n this.raf = requestAnimationFrame(tick);\n }\n\n /** carry the slider to a stop on a spring, carrying the throw's momentum */\n private springTo(target: number) {\n this.stop();\n this.goal = target;\n let last = performance.now();\n const tick = (now: number) => {\n const dt = Math.min((now - last) / 1000, 1 / 30);\n last = now;\n const a = HOME.stiffness * (target - this.p) - HOME.damping * this.v;\n this.v += a * dt;\n this.p = clamp(this.p + this.v * dt, 0, LAST);\n this.draw();\n if (Math.abs(target - this.p) < 0.002 && Math.abs(this.v) < 0.04) {\n this.p = target;\n this.v = 0;\n this.draw();\n this.raf = 0;\n return;\n }\n this.raf = requestAnimationFrame(tick);\n };\n this.raf = requestAnimationFrame(tick);\n }\n\n /**\n * Arrow keys walk the stops, Home and End jump to the ends.\n *\n * The same eased ride a tap gets, for the same reason: a key press has no\n * momentum to spend. A rack that can be dragged but not tabbed to is a\n * control only half of the people who need it can use.\n */\n key = (untyped: Event) => {\n const event = untyped as KeyboardEvent;\n // Walk from the GOAL, not from where the playhead happens to be.\n //\n // Deriving the next stop from `Math.round(this.p)` looks equivalent and\n // is not: a held key repeats while the previous ride is still in the air,\n // the playhead is still rounding to the stop it left, and every repeat\n // re-targets the same destination. The rack stalls one stop short and\n // then completes when you let go, which is exactly backwards.\n const step: Record<string, number> = {\n ArrowDown: this.goal - 1,\n ArrowLeft: this.goal - 1,\n ArrowRight: this.goal + 1,\n ArrowUp: this.goal + 1,\n End: LAST,\n Home: 0,\n };\n const target = step[event.key];\n if (target === undefined) {\n return;\n }\n event.preventDefault();\n this.aim(clamp(target, 0, LAST));\n };\n\n /** a notch, clicked: the same spring, a stated target */\n pick = (untyped: Event) => {\n const event = untyped as MouseEvent;\n const notch = Number(\n (event.currentTarget as HTMLElement).dataset['notch'] ?? 0,\n );\n this.aim(clamp(notch, 0, LAST));\n };\n\n /**\n * Clicking anywhere in the demo hands the keyboard to the rack.\n *\n * The rack is the slider and holds the focus, but nobody clicks a rack to\n * start using arrow keys — they click the thing they are looking at, which\n * is the tiles. Focusing on any pointerdown in the card means the arrows\n * work the moment you have touched it at all. `preventScroll`, because\n * focusing something should never move the page under you.\n */\n claim = () => {\n this.rail?.focus({ preventScroll: true });\n };\n\n railed = modifier((el: HTMLElement) => {\n this.rail = el;\n // the first stop is where the playhead already is, so it has to read as\n // selected before anyone touches anything — `paint` only writes the class\n // on a change, and until the rack exists there is nothing to write to\n this.lit = -1;\n this.paint();\n return () => {\n this.rail = undefined;\n };\n });\n\n thumbed = modifier((el: HTMLElement) => {\n this.thumb = el;\n this.paint();\n return () => {\n this.thumb = undefined;\n };\n });\n\n mount = modifier((el: HTMLElement) => {\n (el as HTMLElement & { rack?: Rack }).rack = this;\n const id = requestAnimationFrame(() => {\n this.take = 1;\n });\n return () => {\n cancelAnimationFrame(id);\n this.stop();\n };\n });\n\n /* ── the score's keyframes: one array per property, four stops long ──── */\n\n /** every seat's x, in order — the tile's whole journey in one value */\n xs = (sprite: Sprite) => (seats.get(sprite.id ?? '') ?? []).map((s) => s.x);\n\n ys = (sprite: Sprite) => (seats.get(sprite.id ?? '') ?? []).map((s) => s.y);\n\n /**\n * The axes arrive in two stages, because they mean two different things.\n *\n * The value axis is already true at `points` — the stacks stand on it — so\n * it draws in over the third leg and stays. The frequency axis is only\n * true once the tiles have left their stacks, so it waits for the fourth.\n */\n /**\n * Each piece of furniture fades in only where it is TRUE.\n *\n * A legend that is always on screen is decoration; one that arrives exactly\n * when the arrangement starts making its claim is part of the claim. Every\n * array below is one number per stop, so the whole schedule of what is\n * being asserted is readable in five columns.\n *\n * A–Z Vowels Bag Points Plot Game\n */\n fadeVowels = [0, 1, 0, 0, 0, 0];\n fadeBag = [0, 0, 1, 0, 0, 0];\n fadePoints = [0, 0, 0, 1, 1, 0];\n fadePlot = [0, 0, 0, 0, 1, 0];\n fadeGame = [0, 0, 0, 0, 0, 1];\n /**\n * The blanks are part of the SET, not of the last two claims.\n *\n * They belong in A–Z, in the vowel split and in both distributions,\n * because a set contains them. They have no letter frequency to plot and\n * no part in a pangram that uses every letter exactly once, so they leave\n * before those two rather than stand around implying otherwise.\n */\n fadeBlank = [1, 1, 1, 1, 0, 0];\n\n /** test-support: what the slider and the score are doing */\n get debug() {\n const run = this.run;\n return {\n duration: run?.duration ?? null,\n p: this.p,\n time: run?.time ?? null,\n };\n }\n\n /** test-support: put the slider somewhere, as the thumb would */\n seek(p: number) {\n this.stop();\n this.p = clamp(p, 0, LAST);\n this.goal = Math.round(this.p);\n this.draw();\n }\n\n letters = letters;\n values = values;\n counts = counts;\n\n <template>\n <div\n class='ex no-select'\n {{on 'selectstart' preventSelect}}\n {{on 'pointerdown' this.claim}}\n {{this.mount}}\n >\n <div class='rk-fit-box'><div class='rk-fit'>\n {{! the region is the stage; the tiles are children ON it }}\n <Choreo class='rk-stage' as |c|>\n <span\n class='rk-wire'\n data-take={{this.take}}\n {{this.wire c}}\n ></span>\n\n {{! Every piece of furniture belongs to ONE arrangement and fades in\n where it becomes true — see the fade arrays above. Nothing is ever\n added or removed: a changeset that gains a participant mid-gesture\n is a different kind of pass. }}\n <span class='rk-key is-v' {{motion id='key-v' role='vowels'}}>\n Vowels\n </span>\n <span class='rk-key is-c' {{motion id='key-c' role='vowels'}}>\n Consonants\n </span>\n\n <span\n class='rk-axis is-x'\n {{motion id='axis-bag' role='bag'}}\n ></span>\n <span class='rk-alab is-x' {{motion id='lab-bag' role='bag'}}>\n Tiles in the bag\n </span>\n {{#each this.counts as |count|}}\n <span\n class='rk-tick'\n style={{tickAt count this.counts}}\n {{motion id=(tickId 'bag' count) role='bag'}}\n >{{count}}</span>\n {{/each}}\n\n <span\n class='rk-axis is-x'\n {{motion id='axis-x' role='points'}}\n ></span>\n <span class='rk-alab is-x' {{motion id='lab-x' role='points'}}>\n Scrabble points\n </span>\n {{#each this.values as |value|}}\n <span\n class='rk-tick'\n style={{tickAt value this.values}}\n {{motion id=(tickId 'pts' value) role='points'}}\n >{{value}}</span>\n {{/each}}\n\n {{! the board, which is only true at the last stop }}\n <span class='rk-grid' {{motion id='grid' role='game'}}></span>\n <span class='rk-alab is-game' {{motion id='lab-game' role='game'}}>\n Cwm fjord bank glyphs vext quiz · 26 tiles, no letter twice\n </span>\n\n <span\n class='rk-axis is-y'\n {{motion id='axis-y' role='plot'}}\n ></span>\n <span class='rk-alab is-yt' {{motion id='lab-yt' role='plot'}}>\n ↑ Rarer\n </span>\n <span class='rk-alab is-yb' {{motion id='lab-yb' role='plot'}}>\n ↓ Commoner\n </span>\n\n {{#each this.letters as |l|}}\n <span\n class='rk-l {{if l.vowel \"is-vowel\"}} {{if l.blank \"is-blank\"}}'\n {{motion id=l.key role=(if l.blank 'blank' 'tile')}}\n >\n <b>{{l.ch}}</b>\n {{#unless l.blank}}<i>{{l.worth}}</i>{{/unless}}\n </span>\n {{/each}}\n\n <c.Sequence>\n {{! the gate is what keeps the score still until the slider asks }}\n <c.Gate />\n <c.Parallel>\n {{! Four stops, evenly spaced along one clock. Linear, because\n under a finger there is no time to have an opinion about —\n the hand is already the curve. }}\n {{! A role is a single value, not a list, so the blanks cannot be\n both \"tile\" and \"blank\". `@of` takes an ARRAY of queries, which\n is the honest way to say \"everything that moves\": the letters\n and the blanks travel together, and only the blanks also\n fade. }}\n <c.Tween\n @of={{array (c.role 'tile') (c.role 'blank')}}\n @x={{this.xs}}\n @y={{this.ys}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'vowels'}}\n @opacity={{this.fadeVowels}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'bag'}}\n @opacity={{this.fadeBag}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'points'}}\n @opacity={{this.fadePoints}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'plot'}}\n @opacity={{this.fadePlot}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'blank'}}\n @opacity={{this.fadeBlank}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n <c.Tween\n @of={{c.role 'game'}}\n @opacity={{this.fadeGame}}\n @duration={{tuneSeconds 'rack' SPAN 'SPAN duration'}}\n @ease={{LINEAR}}\n />\n </c.Parallel>\n </c.Sequence>\n </Choreo>\n\n {{! the notches are pointer shortcuts to a stop, out of the tab\n order; the keyboard moves the rail with its arrow keys }}\n <div\n {{! template-lint-disable no-nested-interactive require-presentational-children }}\n class='rk-rail'\n role='slider'\n tabindex='0'\n aria-label='Arrangement'\n aria-valuemin='0'\n aria-valuemax={{LAST}}\n aria-valuenow='0'\n aria-valuetext={{STOP_ZERO}}\n {{this.railed}}\n {{on 'keydown' this.key}}\n {{on 'pointerdown' this.grab}}\n {{on 'pointermove' this.move}}\n {{on 'pointerup' this.land}}\n {{on 'pointercancel' this.land}}\n >\n <span class='rk-track'></span>\n {{#each STOPS as |stop i|}}\n <button\n type='button'\n class='rk-notch'\n tabindex='-1'\n data-notch={{i}}\n style={{notch i}}\n {{on 'click' this.pick}}\n ><span>{{stop}}</span></button>\n {{/each}}\n <span class='rk-thumb' {{this.thumbed}}></span>\n </div>\n\n </div></div>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n /* THE BOX THE SCALED CONTROL OCCUPIES.\n *\n * `transform` does not change a layout box, so a scaled control still claims\n * its full unscaled height and paints over whatever follows — which is how\n * the rack ended up printed across the card's own title. The wrapper carries\n * the scaled size as real layout; the control inside it is what gets scaled.\n *\n * `--k` comes from container queries below rather than from a\n * ResizeObserver. Every seat here is arithmetic in a fixed 340-wide space, so\n * the control cannot reflow — but which of a handful of sizes it should be is\n * a question about the space it was given, and that is exactly what a\n * container query answers. No observer, no imperative write, and it settles\n * in the same pass as the layout it depends on. */\n .rk-fit-box {\n width: calc(340px * var(--k, 1));\n height: calc(376px * var(--k, 1));\n }\n\n @container (min-width: 430px) {\n .stage-wrap .rk-fit-box {\n /* Room is not a reason to be bigger.\n *\n * At 1.5 the control simply grew until it hit the walls, which makes a\n * 22px tile into a 33px one and reads as a zoom rather than as a demo\n * with space around it. A modest step up and the rest spent on margin is\n * the better trade: the tiles stay the size they were designed at, and\n * the thing gets room to sit in. */\n --k: 1.18;\n }\n }\n @container (max-width: 339px) {\n .rk-fit-box {\n --k: 0.96;\n }\n }\n\n @container (max-width: 325px) {\n .rk-fit-box {\n --k: 0.92;\n }\n }\n\n @container (max-width: 312px) {\n .rk-fit-box {\n --k: 0.88;\n }\n }\n\n @container (max-width: 298px) {\n .rk-fit-box {\n --k: 0.84;\n }\n }\n\n @container (max-width: 285px) {\n .rk-fit-box {\n --k: 0.78;\n }\n }\n\n @container (max-width: 264px) {\n .rk-fit-box {\n --k: 0.7;\n }\n }\n\n /* Whatever is left over after the control has taken its size becomes margin,\n evenly, rather than being absorbed by making everything larger. */\n .ex:has(.rk-fit-box) {\n display: grid;\n place-items: center;\n padding-block: 16px;\n }\n\n /* One control, scaled as a whole.\n *\n * Every seat in this demo is arithmetic in a fixed 340-wide space — which is\n * what lets the journey be keyframes rather than measurements, and also means\n * it cannot reflow. In the gallery grid and on a phone it was simply cropped,\n * and what got cropped was the rack: the demo lost the one thing you touch.\n * Scaling the whole thing keeps every proportion and every hit target in\n * agreement with the arithmetic. */\n .rk-fit {\n width: 340px;\n height: 376px;\n transform: scale(var(--k, 1));\n transform-origin: top left;\n }\n\n .ex:has(.rk-stage) {\n container-type: inline-size;\n --tile: #f0e3c2;\n --tile-lip: rgba(122, 92, 44, 0.42);\n --tile-top: rgba(255, 253, 244, 0.85);\n --tile-ink: #3a2b16;\n }\n\n .rk-stage {\n position: relative;\n width: 340px;\n height: 292px;\n }\n\n /* A TILE, as an object.\n *\n * Two inset shadows do the whole job: a light line along the top edge and a\n * darker one along the bottom, which is what a chamfer looks like from\n * straight on. The letter is a display face rather than the mono the rest of\n * the page uses — a Scrabble tile is printed, not typeset. */\n .rk-l {\n position: absolute;\n left: 0;\n top: 0;\n width: 22px;\n height: 22px;\n display: flex;\n align-items: center;\n justify-content: center;\n border-radius: 5px;\n background:\n radial-gradient(\n 120% 120% at 30% 20%,\n rgba(255, 255, 255, 0.55),\n rgba(255, 255, 255, 0) 60%\n ),\n var(--tile);\n color: var(--tile-ink);\n box-shadow:\n inset 0 -2.5px 0 var(--tile-lip),\n inset 0 1.5px 0 var(--tile-top),\n inset 2px 0 0 rgba(255, 253, 244, 0.35),\n inset -2px 0 0 rgba(122, 92, 44, 0.18),\n 0 2px 3px rgba(var(--shadow-rgb), calc(0.55 * var(--shadow-a))),\n 0 -1px 2px rgba(var(--shadow-rgb), calc(0.3 * var(--shadow-a)));\n font-family: var(--font-display);\n font-size: 13px;\n line-height: 1;\n }\n\n .rk-l b {\n font-weight: 700;\n text-transform: uppercase;\n /* optically centred: the value in the corner pulls the eye down-right */\n transform: translate(-1px, -1.5px);\n }\n\n .rk-l i {\n position: absolute;\n right: 2px;\n bottom: 1px;\n font-family: var(--font-mono);\n font-style: normal;\n font-size: 7px;\n font-weight: 700;\n opacity: 0.6;\n }\n\n /* vowels are marked in EVERY arrangement, not only the one that groups them:\n a colour that arrived on a pose change would have nothing to fade with */\n .rk-l.is-vowel {\n --tile: #f6d7a2;\n color: #6b3a10;\n }\n\n /* a blank has no letter and no value — it is the absence, drawn */\n .rk-l.is-blank {\n --tile: #e7dcc4;\n }\n\n /* ---- the furniture, each piece true in one arrangement ---- */\n\n .rk-axis {\n position: absolute;\n background: var(--line);\n opacity: 0;\n }\n\n .rk-axis.is-x {\n left: 12px;\n right: 6px;\n top: 262px;\n height: 1px;\n }\n\n .rk-axis.is-y {\n left: 12px;\n top: 10px;\n bottom: 36px;\n width: 1px;\n }\n\n .rk-alab,\n .rk-tick {\n position: absolute;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-faint);\n opacity: 0;\n white-space: nowrap;\n }\n\n .rk-alab.is-x {\n right: 8px;\n top: 276px;\n }\n\n .rk-alab.is-yt {\n left: 20px;\n top: 10px;\n }\n\n .rk-alab.is-yb {\n left: 20px;\n bottom: 40px;\n }\n\n .rk-tick {\n top: 266px;\n transform: translateX(-50%);\n font-size: 9px;\n color: var(--ink-dim);\n }\n\n /* The board: fifteen-by-fifteen is wider than this stage, so it is thirteen\n squares — enough for the longest word with room around it. Drawn with\n gradients rather than 169 elements, because it is scenery: nothing on it\n moves, and the tiles that land on it are the participants. */\n .rk-grid {\n position: absolute;\n left: 33px;\n top: 8px;\n width: 242px;\n height: 242px;\n border-radius: 4px;\n background-color: #e6ddcd;\n background-image:\n linear-gradient(\n to right,\n rgba(120, 96, 60, 0.22) 1px,\n transparent 1px\n ),\n linear-gradient(\n to bottom,\n rgba(120, 96, 60, 0.22) 1px,\n transparent 1px\n );\n background-size: 22px 22px;\n box-shadow:\n inset 0 0 0 1px rgba(120, 96, 60, 0.35),\n 0 2px 8px rgba(var(--shadow-rgb), calc(0.35 * var(--shadow-a)));\n opacity: 0;\n }\n\n .choreo-site:not([data-theme='light']) .rk-grid {\n background-color: #3a332b;\n background-image:\n linear-gradient(\n to right,\n rgba(226, 205, 170, 0.16) 1px,\n transparent 1px\n ),\n linear-gradient(\n to bottom,\n rgba(226, 205, 170, 0.16) 1px,\n transparent 1px\n );\n }\n\n .rk-alab.is-game {\n left: 12px;\n top: 258px;\n text-transform: none;\n letter-spacing: 0.04em;\n font-size: 9px;\n color: var(--ink-dim);\n }\n\n .rk-key {\n position: absolute;\n top: 232px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n color: var(--ink-dim);\n opacity: 0;\n white-space: nowrap;\n }\n\n .rk-key::before {\n content: '';\n width: 13px;\n height: 13px;\n border-radius: 3px;\n background: var(--tile);\n box-shadow: inset 0 -2px 0 var(--tile-lip);\n }\n\n .rk-key.is-v {\n left: 40px;\n }\n\n .rk-key.is-v::before {\n background: #f6d7a2;\n }\n\n .rk-key.is-c {\n left: 150px;\n }\n\n /* ---- the rack: a slider dressed as the thing it is ---- */\n\n .rk-rail {\n --pad: 15px;\n position: relative;\n width: 340px;\n height: 28px;\n margin-top: 8px;\n padding: 0 var(--pad);\n border-radius: 6px 6px 9px 9px;\n background: linear-gradient(180deg, #8a6236, #6d4a26 62%, #5b3d1f);\n box-shadow:\n inset 0 2px 0 rgba(255, 226, 187, 0.32),\n inset 0 -3px 0 rgba(0, 0, 0, 0.28),\n 0 3px 6px rgba(var(--shadow-rgb), calc(0.6 * var(--shadow-a)));\n touch-action: none;\n cursor: grab;\n }\n\n .rk-rail:active {\n cursor: grabbing;\n }\n\n /* the groove the marker slides in, filled up to where the playhead is */\n .rk-track {\n position: absolute;\n left: var(--pad);\n right: var(--pad);\n top: 13px;\n height: 5px;\n border-radius: 999px;\n background: linear-gradient(\n 90deg,\n rgba(255, 226, 187, 0.55) var(--at, 0%),\n rgba(0, 0, 0, 0.3) var(--at, 0%)\n );\n box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.45);\n }\n /* Positioned inside the TRACK's span, not the rack's.\n `--f` is a plain fraction the component writes each frame; the arithmetic\n that turns it into a place belongs here, where the padding is known. */\n .rk-thumb {\n position: absolute;\n top: 3px;\n left: calc(var(--pad) + (100% - 2 * var(--pad)) * var(--f, 0));\n transform: translateX(-50%);\n width: 22px;\n height: 22px;\n border-radius: 4px;\n background: var(--tile);\n box-shadow:\n inset 0 -2px 0 var(--tile-lip),\n inset 0 1.5px 0 var(--tile-top),\n 0 2px 4px rgba(0, 0, 0, 0.45);\n pointer-events: none;\n }\n\n /* the stops sit under the rack, and the one in force says so */\n .rk-notch {\n position: absolute;\n top: 34px;\n left: calc(var(--pad) + (100% - 2 * var(--pad)) * var(--f, 0));\n transform: translateX(-50%);\n padding: 3px 6px;\n border: 0;\n border-radius: 5px;\n background: none;\n cursor: pointer;\n font-family: var(--font-mono);\n font-size: 9px;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-faint);\n white-space: nowrap;\n transition:\n color 0.18s ease,\n background 0.18s ease,\n font-size 0.18s ease;\n }\n\n /* The end labels are centred on the ends of the TRACK, which leaves them\n floating a padding's width inside the rack rather than agreeing with it.\n The first is flush with the rack's left edge and the last with its right,\n so the row of stops spans exactly what the rack spans — and neither can\n hang off a narrow card. */\n .rk-notch:first-of-type {\n left: 0;\n transform: translateX(0);\n }\n\n .rk-notch:last-of-type {\n left: 100%;\n transform: translateX(-100%);\n }\n\n @media (hover: hover) {\n .rk-notch:hover {\n color: var(--ink-dim);\n }\n }\n\n .rk-notch.is-on {\n font-size: 11px;\n font-weight: 700;\n color: var(--tile-ink);\n background: var(--tile);\n box-shadow: inset 0 -2px 0 var(--tile-lip);\n }\n\n .rk-wire {\n display: none;\n }\n\n /* a Scrabble tile is a physical object, and the one thing it is not is dark */\n .choreo-site:not([data-theme='light']) .rk-l,\n .choreo-site:not([data-theme='light']) .rk-thumb {\n --tile-lip: rgba(92, 68, 28, 0.55);\n }\n\n .choreo-site:not([data-theme='light']) .rk-notch.is-on {\n color: #3a2b16;\n }\n @container (max-height: 430px) {\n .rk-fit-box {\n transform: scale(0.95);\n transform-origin: center;\n }\n }\n </style>\n </template>\n}\n\n/**\n * A tick sits under the middle of the column its value owns.\n *\n * The bag's counts are evenly spaced and the points are logarithmic, so the\n * two axes are not the same ruler — which is exactly why they fade in and out\n * with their own arrangements rather than sharing one row of labels.\n */\nfunction tickAt(value: number, scale: number[]) {\n const lanes = scale === values ? PTS_LANES : BAG_LANES;\n return htmlSafe(`left: ${lanes.centres[scale.indexOf(value)]!}px`);\n}\n\nfunction tickId(axis: string, value: number) {\n return `tick-${axis}-${value}`;\n}\n\nfunction notch(i: number) {\n return htmlSafe(`--f: ${i / LAST}`);\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSeconds('rack', SPAN, 'SPAN duration');\n\nexport class RackDemo extends GalleryDemo {\n static stage = Rack;\n static notes = RackNotes;\n}\n";
517
517
  //#endregion
518
518
  //#region ../choreo-gallery/realm/stages/reorder.gts?raw
519
519
  var reorder_default = "import { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { ReorderGroup, ReorderItem } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { preventSelect } from '../lib/pointer';\nimport { tuneObject } from '../lib/tuning';\n\n/**\n * A colour per track, for the same reason the grid has one: a row you are\n * dragging past four near-identical rows is hard to follow, and the hue is\n * what your eye actually tracks while the order changes underneath it.\n */\nconst tracks = [\n {\n artist: 'Night shift',\n hue: '#ff7a45',\n id: 'atlas',\n time: '3:41',\n title: 'Atlas',\n },\n {\n artist: 'Kiln floor',\n hue: '#6fd6c4',\n id: 'ember',\n time: '4:02',\n title: 'Ember',\n },\n {\n artist: 'Cooling rack',\n hue: '#8ba6ff',\n id: 'flux',\n time: '2:58',\n title: 'Flux',\n },\n {\n artist: 'Foundry glass',\n hue: '#c78bff',\n id: 'halo',\n time: '3:17',\n title: 'Halo',\n },\n {\n artist: 'Late heat',\n hue: '#b6d15a',\n id: 'ore',\n time: '5:04',\n title: 'Ore',\n },\n];\n\nconst whileDrag = {\n boxShadow: '0 18px 40px rgba(0, 0, 0, 0.45)',\n scale: 1.04,\n};\nconst snap = { bounce: 0.1, type: 'spring', visualDuration: 0.2 } as const;\n\nexport class ReorderList extends Component {\n @tracked items = tracks;\n\n get playing() {\n return this.items[0]?.id;\n }\n\n setItems = (items: typeof tracks) => {\n this.items = items;\n };\n\n cycle = () => {\n this.setItems([...this.items.slice(1), this.items[0]!]);\n };\n\n <template>\n <div class='ex no-select' {{on 'selectstart' preventSelect}}>\n <button\n type='button'\n class='replay'\n data-tour-reorder\n {{on 'click' this.cycle}}\n >Move first to last</button>\n <ReorderGroup\n class='tracks'\n @values={{this.items}}\n @onReorder={{this.setItems}}\n @axis='y'\n as |group|\n >\n {{#each this.items as |track|}}\n <ReorderItem\n class={{if (isOn track.id this.playing) 'track is-on' 'track'}}\n @group={{group}}\n @value={{track}}\n @transition={{tuneObject 'reorder' snap 'ReorderItem transition 1'}}\n @whileDrag={{tuneObject\n 'reorder'\n whileDrag\n 'ReorderItem whileDrag 2'\n }}\n {{! a custom property, through the modifier — Motion owns a motion\n element's inline style, so a bound style= attribute here would\n be wiped by the next transform it writes }}\n @style={{trackStyle track.hue}}\n >\n <span class='grip' aria-hidden='true'></span>\n <span class='track-hue' aria-hidden='true'></span>\n <span class='track-copy'>\n <strong>{{track.title}}</strong>\n <small>{{track.artist}}</small>\n </span>\n <em>{{track.time}}</em>\n </ReorderItem>\n {{/each}}\n </ReorderGroup>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .no-select,\n .no-select * {\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n }\n\n .replay {\n position: absolute;\n top: 14px;\n right: 14px;\n z-index: 3;\n border: 1px solid var(--line-strong);\n background: rgba(var(--bg-rgb), 0.72);\n backdrop-filter: blur(8px);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .replay:hover {\n color: var(--ink);\n }\n }\n\n .tracks {\n list-style: none;\n margin: 0;\n padding: 0;\n touch-action: none;\n }\n\n .tracks {\n width: min(90%, 320px);\n display: flex;\n flex-direction: column;\n gap: 8px;\n }\n\n .track {\n display: flex;\n align-items: center;\n gap: 10px;\n border-radius: 16px;\n background: var(--bg-spot);\n border: 1px solid var(--line);\n padding: 10px 12px;\n cursor: grab;\n touch-action: none;\n }\n\n .track:active {\n cursor: grabbing;\n }\n\n .track.is-on {\n border-color: rgba(255, 59, 31, 0.35);\n box-shadow: inset 0 0 0 1px rgba(255, 59, 31, 0.18);\n }\n\n .track-hue {\n width: 4px;\n align-self: stretch;\n min-height: 22px;\n border-radius: 2px;\n background: var(--hue, var(--ember));\n flex: none;\n }\n\n .track-copy {\n flex: 1;\n min-width: 0;\n }\n\n .grip {\n width: 10px;\n height: 16px;\n background: radial-gradient(\n circle,\n var(--ink-faint) 1.1px,\n transparent 1.3px\n )\n 0 0 / 5px 6px;\n opacity: 0.7;\n flex: none;\n }\n\n .track strong {\n display: block;\n font-size: 13px;\n letter-spacing: -0.02em;\n }\n\n .track small,\n .track em {\n color: var(--ink-dim);\n font-size: 11px;\n }\n\n .track em {\n font-style: normal;\n font-family: var(--font-mono);\n font-size: 10px;\n }\n\n @container (max-width: 420px) {\n .tracks {\n width: min(92%, 100%);\n max-width: 100%;\n }\n }\n\n .choreo-site:not([data-theme='light']) .track {\n background: #322c27;\n }\n </style>\n </template>\n}\n\nfunction isOn(id: string, playing?: string) {\n return id === playing;\n}\n\nfunction trackStyle(hue: string) {\n return { '--hue': hue };\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneObject('reorder', snap, 'ReorderItem transition 1');\ntuneObject('reorder', whileDrag, 'ReorderItem whileDrag 2');\n\nexport class ReorderListDemo extends GalleryDemo {\n static stage = ReorderList;\n}\n";
@@ -555,7 +555,7 @@ var towers_default = "import type { TOC } from '@ember/component/template-only';
555
555
  var trail_default = "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { motion, Presence } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneMotion } from '../lib/tuning';\nimport TrailNotes from '../notes/trail';\n\nconst path = ['Kiln', 'Floor', 'Atlas', 'Night'] as const;\n\nconst keyOf = (item: { id: string }) => item.id;\nconst initial = { opacity: 0, x: -12 };\nconst animate = { opacity: 1, x: 0 };\n// out the way it came in: a popped crumb retreats to the left, back down the\n// path, rather than drifting off the end of it\nconst exit = { opacity: 0, scale: 0.92, x: -14 };\nconst spring = { bounce: 0.16, type: 'spring', visualDuration: 0.32 } as const;\n\ntype Step = { id: string; label: string; tip: boolean };\n\nexport class Trail extends Component {\n @tracked depth = 2;\n\n get items(): Step[] {\n return path.slice(0, this.depth).map((label, index) => ({\n id: label,\n label,\n tip: index === this.depth - 1,\n }));\n }\n\n push = () => {\n if (this.depth < path.length) {\n this.depth += 1;\n }\n };\n\n popTo = (index: number) => {\n this.depth = index + 1;\n };\n\n <template>\n <div class='ex'>\n <button\n type='button'\n class={{if (atEnd this.depth) 'replay is-off' 'replay'}}\n {{on 'click' this.push}}\n >Push</button>\n <nav class='trail' aria-label='Path'>\n <Presence\n @items={{this.items}}\n @key={{keyOf}}\n @mode='popLayout'\n @initial={{false}}\n as |step h|\n >\n <span\n class='crumb'\n {{motion\n presence=h\n layout=true\n initial=initial\n animate=animate\n exit=exit\n transition=(tuneMotion 'trail' spring 'spring')\n }}\n >\n {{#if (hasSep step.id)}}\n <span class='crumb-sep' aria-hidden='true'>›</span>\n {{/if}}\n <button\n type='button'\n class={{if step.tip 'crumb-btn is-tip' 'crumb-btn'}}\n disabled={{step.tip}}\n {{on 'click' (fn this.popTo (indexOf step.id))}}\n >{{step.label}}</button>\n </span>\n </Presence>\n </nav>\n <p class='trail-hint'>Click a crumb to pop back.</p>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n .replay {\n position: absolute;\n top: 14px;\n right: 14px;\n z-index: 3;\n border: 1px solid var(--line-strong);\n background: rgba(var(--bg-rgb), 0.72);\n backdrop-filter: blur(8px);\n border-radius: 999px;\n padding: 7px 12px;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-dim);\n }\n\n @media (hover: hover) {\n .replay:hover {\n color: var(--ink);\n }\n }\n\n .replay.is-off {\n opacity: 0.35;\n pointer-events: none;\n }\n\n .trail {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 0;\n width: min(88%, 320px);\n min-height: 48px;\n padding: 8px 12px;\n border: 1px solid var(--line-strong);\n border-radius: 16px;\n background: var(--bg-spot);\n }\n\n .crumb {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n\n .crumb-sep {\n color: var(--ink-faint);\n font-size: 14px;\n }\n\n .crumb-btn {\n border: 0;\n padding: 4px 2px;\n background: transparent;\n color: var(--ember-hot);\n font-family: var(--font-display);\n font-size: 1.05rem;\n letter-spacing: -0.03em;\n }\n\n .crumb-btn.is-tip {\n color: var(--ink);\n pointer-events: none;\n }\n\n .trail-hint {\n position: absolute;\n bottom: 18px;\n margin: 0;\n font-family: var(--font-mono);\n font-size: 10px;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n @container (max-width: 420px) {\n .trail {\n width: min(92%, 100%);\n max-width: 100%;\n }\n }\n\n /* ---- dark: the surfaces the light-mode sweep flattened ----\n *\n * Adding light mode introduced --bg-well (#070605) and pointed a lot of demo\n * surfaces at it. It is darker than --bg itself, and darker than every literal\n * it replaced (#0a0908, #100e0c, #0d0b0a, #16130f …), so demos that used to sit\n * a shade ABOVE the page became holes cut into it — and neighbouring tones that\n * were deliberately different (.sub-tile vs .sub-tile.is-on) collapsed onto one\n * value.\n *\n * These restore each surface's own pre-light-mode tone, for dark only. The\n * tokenised rule above stays as written and is what light mode uses, so this\n * block cannot affect it: `:not([data-theme='light'])` matches dark and the\n * unstamped default, never light.\n */\n\n .choreo-site:not([data-theme='light']) .trail {\n background: #2e2925;\n }\n </style>\n </template>\n}\n\nfunction atEnd(depth: number) {\n return depth >= path.length;\n}\n\nfunction hasSep(id: string) {\n return id !== path[0];\n}\n\nfunction indexOf(id: string) {\n return path.indexOf(id as (typeof path)[number]);\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneMotion('trail', spring, 'spring');\n\nexport class TrailDemo extends GalleryDemo {\n static stage = Trail;\n static notes = TrailNotes;\n}\n";
556
556
  //#endregion
557
557
  //#region ../choreo-gallery/realm/stages/wires.gts?raw
558
- var wires_default = "import { Choreo, type Rect } from '@cardstack/choreo';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, spring } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { tuneSeconds, tuneSpring } from '../lib/tuning';\nimport WiresNotes from '../notes/wires';\n\nconst GLIDE = spring({ damping: 28, stiffness: 240 });\n\n/**\n * Smooth, always. The lift fans overlapping cubics so three threads can\n * share a gap without an elbow.\n */\nconst curve =\n (lift = 0) =>\n (a: Rect, b: Rect) => {\n const x1 = a.x + a.width;\n const y1 = a.y + a.height / 2;\n const x2 = b.x;\n const y2 = b.y + b.height / 2;\n const mx = (x1 + x2) / 2;\n return `M ${x1} ${y1} C ${mx} ${y1 + lift}, ${mx} ${y2 + lift}, ${x2} ${y2}`;\n };\n\ntype ThreadId = 'edition' | 'gauge' | 'hed';\n\ninterface Piece {\n id?: string;\n key: string;\n text: string;\n thread?: ThreadId;\n}\n\ninterface Note {\n body: string;\n by: string;\n id: string;\n thread: ThreadId;\n}\n\ninterface Draft {\n copy: Piece[];\n notes: Note[];\n}\n\nconst PATH_GAUGE = curve(-10);\nconst PATH_EDITION = curve(0);\nconst PATH_HED = curve(12);\n\nconst THREADS: {\n from: string;\n path: (a: Rect, b: Rect) => string;\n thread: ThreadId;\n to: string;\n}[] = [\n { from: 'm-gauge', path: PATH_GAUGE, thread: 'gauge', to: 'c-gauge' },\n { from: 'm-edition', path: PATH_EDITION, thread: 'edition', to: 'c-edition' },\n { from: 'm-hed', path: PATH_HED, thread: 'hed', to: 'c-hed' },\n];\n\nconst DRAFTS: Draft[] = [\n {\n copy: [\n { key: 'a0', text: 'The river ' },\n { id: 'm-gauge', key: 'g', text: 'rose overnight', thread: 'gauge' },\n { key: 'a1', text: '. We hold the ' },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n { key: 'a2', text: ' for the levee update.' },\n ],\n notes: [\n {\n body: 'Confirm with the gauge at first light.',\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: 'City desk wants 800, not 400.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n ],\n },\n {\n copy: [\n { key: 'a0', text: 'The river ' },\n { id: 'm-gauge', key: 'g', text: 'rose overnight', thread: 'gauge' },\n {\n key: 'a1',\n text: ', and the lower ward is already sandbagging. We hold the ',\n },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n { key: 'a2', text: ' for the levee update, then lead with the ' },\n { id: 'm-hed', key: 'h', text: \"mayor's walk\", thread: 'hed' },\n { key: 'a3', text: '.' },\n ],\n notes: [\n {\n body: \"Gauge at 14.2. Don't say flood until they do.\",\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: '800. Keep the levee in the hed.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n {\n body: \"She hasn't confirmed. Hedge it.\",\n by: 'Jo',\n id: 'c-hed',\n thread: 'hed',\n },\n ],\n },\n {\n copy: [\n {\n key: 'a0',\n text: 'The river rose overnight, and the lower ward is already sandbagging. We hold the ',\n },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n {\n key: 'a2',\n text: \" for the levee update, then lead with the mayor's walk — \",\n },\n { id: 'm-hed', key: 'h', text: 'if she walks', thread: 'hed' },\n { key: 'a3', text: \". If she doesn't, \" },\n {\n id: 'm-gauge',\n key: 'g',\n text: 'the gauge is the story',\n thread: 'gauge',\n },\n { key: 'a4', text: '.' },\n ],\n notes: [\n {\n body: \"That's the hed. Play it.\",\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: '800 stands.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n {\n body: 'Confirmed 7am. Drop the hedge.',\n by: 'Jo',\n id: 'c-hed',\n thread: 'hed',\n },\n ],\n },\n];\n\n/**\n * Resting ink — the same cubics the run draws. Paths are kept by thread id\n * so a connected wire is retargeted, not torn down and faded back in.\n */\nfunction paintRest(svg: SVGSVGElement) {\n const root = svg.closest('.wires');\n if (!root) {\n return;\n }\n // Client rects carry every ancestor transform — including the page\n // crossing, which is scaling this whole stage mid-flight while the\n // follow loop repaints. Subtracting raw client coordinates baked that\n // scale into the ink and the threads stood ~10px off their marks for\n // the whole flight; mapping through the svg's own screen CTM lands\n // them in user units no matter what is carrying the page this frame —\n // the same mapping, for the same reason, as the engine's drawTether.\n const inverse = svg.getScreenCTM()?.inverse();\n const R = root.getBoundingClientRect();\n const toLocal = (x: number, y: number) => {\n if (!inverse) {\n return { x: x - R.left, y: y - R.top };\n }\n const p = new DOMPoint(x, y).matrixTransform(inverse);\n return { x: p.x, y: p.y };\n };\n const box = (id: string): Rect | null => {\n const el = root.querySelector(`[data-node=\"${id}\"]`);\n if (!el) {\n return null;\n }\n const r = el.getBoundingClientRect();\n const tl = toLocal(r.left, r.top);\n const br = toLocal(r.right, r.bottom);\n return {\n height: br.y - tl.y,\n width: br.x - tl.x,\n x: tl.x,\n y: tl.y,\n };\n };\n const keep = new Set<string>();\n for (const thread of THREADS) {\n const a = box(thread.from);\n const b = box(thread.to);\n if (!a || !b) {\n continue;\n }\n keep.add(thread.thread);\n let path = svg.querySelector(\n `:scope > [data-thread=\"${thread.thread}\"]`,\n ) as SVGPathElement | null;\n if (!path) {\n path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n path.setAttribute('class', 'wires-thread');\n path.setAttribute('data-thread', thread.thread);\n svg.append(path);\n }\n path.setAttribute('d', thread.path(a, b));\n }\n for (const path of [...svg.querySelectorAll(':scope > path')]) {\n const id = path.getAttribute('data-thread');\n if (!id || !keep.has(id)) {\n path.remove();\n }\n }\n}\n\nconst eq = (a: number, b: number) => a === b;\nconst VERSIONS = [0, 1, 2];\nconst label = (v: number) => `V${v + 1}`;\nconst draftNo = (v: number) => String(v + 1);\n\n/**\n * A morning note, three drafts. Marks in the prose, comments in the margin;\n * the thread between them is not a tween. The first comment is selected on\n * load; hover or tap another pair to move the thread. Step V1 → V2 → V3 and\n * the copy grows, the comments update, and every wire is asked again on\n * every frame of the spring.\n */\nexport class Wires extends Component {\n @tracked version = 0;\n\n rest = modifier((el: SVGSVGElement, [version]: [number]) => {\n void version;\n const root = el.closest('.wires');\n const layer = root?.querySelector('[data-choreo-tethers]');\n let follow = 0;\n const pin = () => {\n if (!root) {\n return;\n }\n const hot = root.getAttribute('data-hot');\n if (hot && root.querySelector(`[data-thread=\"${hot}\"]`)) {\n return;\n }\n const first = root.querySelector('.wires-note') as HTMLElement | null;\n root.setAttribute('data-hot', first?.dataset.thread ?? 'gauge');\n };\n const draw = () => {\n paintRest(el);\n pin();\n };\n const followDraw = () => {\n draw();\n follow = root?.querySelector('[data-choreo-tether]')\n ? requestAnimationFrame(followDraw)\n : 0;\n };\n const startFollow = () => {\n if (!follow) {\n follow = requestAnimationFrame(followDraw);\n }\n };\n const id = requestAnimationFrame(() => requestAnimationFrame(draw));\n const ro =\n root && typeof ResizeObserver !== 'undefined'\n ? new ResizeObserver(() => requestAnimationFrame(draw))\n : null;\n const mo =\n layer && typeof MutationObserver !== 'undefined'\n ? new MutationObserver(startFollow)\n : null;\n if (root) {\n ro?.observe(root);\n }\n if (layer) {\n mo?.observe(layer, { childList: true });\n }\n return () => {\n cancelAnimationFrame(id);\n cancelAnimationFrame(follow);\n ro?.disconnect();\n mo?.disconnect();\n };\n });\n\n /**\n * Pair a mark with its comment — an attribute on the region, not a tracked\n * write. A tracked hot would re-render the parent and restart the run.\n * Selection sticks: the first comment is on by default; hover or tap\n * another pair to move the thread.\n */\n pair = modifier((el: HTMLElement) => {\n const root = () => el.closest('.wires');\n const id = el.dataset.thread ?? '';\n const select = () => root()?.setAttribute('data-hot', id);\n el.addEventListener('pointerenter', select);\n el.addEventListener('focus', select);\n el.addEventListener('click', select);\n return () => {\n el.removeEventListener('pointerenter', select);\n el.removeEventListener('focus', select);\n el.removeEventListener('click', select);\n };\n });\n\n get draft() {\n return DRAFTS[this.version]!;\n }\n\n go = (version: number) => {\n this.version = version;\n };\n\n isMark = (part: Piece) => Boolean(part.id);\n\n <template>\n <div class='ex'>\n <div class='wires-bar' role='group' aria-label='Draft'>\n <span class='wires-bar-label'>Draft</span>\n {{#each VERSIONS as |v|}}\n <button\n type='button'\n class={{if (eq v this.version) 'is-on'}}\n aria-pressed='{{eq v this.version}}'\n data-test-wires-v={{label v}}\n {{on 'click' (fn this.go v)}}\n >{{draftNo v}}</button>\n {{/each}}\n </div>\n\n <Choreo class='wires' data-hot='gauge' data-test-wires as |c|>\n <svg\n class='wires-rest'\n aria-hidden='true'\n {{this.rest this.version}}\n ></svg>\n\n <div class='wires-board'>\n <article class='wires-paper'>\n <header class='wires-head'>\n <p class='wires-kicker'>\n <span>City desk</span>\n <time datetime='06:12'>6:12 a.m.</time>\n </p>\n <h2 class='wires-hed'>Morning note</h2>\n </header>\n <p class='wires-copy'>\n {{#each this.draft.copy key='key' as |part|}}\n {{#if (this.isMark part)}}\n <mark\n class='wires-mark'\n data-node={{part.id}}\n data-thread={{part.thread}}\n data-test-wires-mark={{part.thread}}\n tabindex='0'\n {{motion id=part.id role='mark'}}\n {{this.pair}}\n >{{part.text}}</mark>\n {{else}}\n {{part.text}}\n {{/if}}\n {{/each}}\n </p>\n </article>\n\n <ol class='wires-margin' aria-label='Comments on this draft'>\n {{#each this.draft.notes key='id' as |note|}}\n <li\n class='wires-note'\n data-node={{note.id}}\n data-thread={{note.thread}}\n data-test-wires-note={{note.thread}}\n tabindex='0'\n {{motion id=note.id role='note'}}\n {{this.pair}}\n >\n <span class='wires-hue' aria-hidden='true'></span>\n <span class='wires-note-copy'>\n <span class='wires-who'>{{note.by}}</span>\n <p>{{note.body}}</p>\n </span>\n </li>\n {{/each}}\n </ol>\n </div>\n\n <c.Parallel>\n <c.Hold @of={{c.role 'note'}} @zIndex={{3}} />\n <c.Move\n @of={{c.moved 'mark'}}\n @spring={{tuneSpring 'wires' GLIDE 'GLIDE'}}\n @size={{false}}\n />\n <c.Move\n @of={{c.moved 'note'}}\n @spring={{tuneSpring 'wires' GLIDE 'GLIDE'}}\n />\n <c.Tween\n @of={{c.removed 'note'}}\n @opacity={{0}}\n @y={{-8}}\n @duration={{tuneSeconds 'wires' 0.28 'Step 1 duration'}}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.inserted 'note'}}\n @opacity={{array 0 1}}\n @y={{array 8 0}}\n @duration={{tuneSeconds 'wires' 0.32 'Step 2 duration'}}\n @ease='easeOut'\n />\n <c.Tether\n @name='gauge'\n @from={{c.id 'm-gauge'}}\n @to={{c.id 'c-gauge'}}\n @path={{PATH_GAUGE}}\n />\n <c.Tether\n @name='edition'\n @from={{c.id 'm-edition'}}\n @to={{c.id 'c-edition'}}\n @path={{PATH_EDITION}}\n />\n <c.Tether\n @name='hed'\n @from={{c.id 'm-hed'}}\n @to={{c.id 'c-hed'}}\n @path={{PATH_HED}}\n />\n </c.Parallel>\n </Choreo>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* ---- Wires: comments that keep their hold ---- */\n\n .ex:has(.wires) {\n padding-block: 20px;\n -webkit-user-select: text;\n user-select: text;\n }\n\n .wires-bar {\n position: absolute;\n top: 14px;\n left: 50%;\n z-index: 4;\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding: 3px 4px 3px 12px;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--bg-elev);\n transform: translateX(-50%);\n }\n\n .wires-bar-label {\n margin-right: 6px;\n font-family: var(--font);\n font-size: 11px;\n font-weight: 500;\n letter-spacing: 0.01em;\n color: var(--ink-faint);\n }\n\n .wires-bar button {\n border: 0;\n background: transparent;\n border-radius: 999px;\n min-width: 26px;\n padding: 4px 9px;\n font-family: var(--font);\n font-size: 12px;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n color: var(--ink-faint);\n }\n\n @media (hover: hover) {\n .wires-bar button:hover {\n color: var(--ink);\n }\n }\n\n .wires-bar button.is-on {\n background: color-mix(in srgb, var(--ink) 8%, transparent);\n color: var(--ink);\n }\n\n .wires {\n position: relative;\n width: min(90cqw, 640px);\n padding: 44px 6px 8px;\n overflow: visible;\n /* inline-size only: a size container with auto height reports 0 block\n size, so the compact query would always match on the demo page. */\n container-type: inline-size;\n }\n\n .wires-board {\n display: grid;\n grid-template-columns: minmax(0, 1.4fr) minmax(148px, 0.68fr);\n grid-template-rows: auto 1fr;\n column-gap: 24px;\n }\n\n .wires-board::before {\n content: '';\n grid-column: 1;\n grid-row: 1 / span 2;\n border: 1px solid var(--line);\n border-radius: 4px;\n background: var(--bg-spot);\n pointer-events: none;\n }\n\n .wires-paper {\n display: contents;\n }\n\n .wires-head {\n grid-column: 1;\n grid-row: 1;\n z-index: 1;\n min-width: 0;\n margin: 0;\n padding: clamp(14px, 4cqw, 26px) clamp(16px, 4.7cqw, 30px)\n clamp(8px, 2.2cqw, 14px);\n border-bottom: 1px solid var(--line);\n }\n\n .wires-kicker {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n margin: 0 0 10px;\n font-family: var(--font);\n font-size: 11px;\n font-weight: 500;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .wires-hed {\n margin: 0;\n font-family: var(--font-display);\n font-size: clamp(1rem, 4.8cqw, 1.9rem);\n font-weight: 700;\n letter-spacing: -0.035em;\n line-height: 1.12;\n color: var(--ink);\n }\n\n .wires-copy {\n grid-column: 1;\n grid-row: 2;\n z-index: 1;\n min-width: 0;\n margin: 0;\n padding: clamp(12px, 3.2cqw, 20px) clamp(16px, 4.7cqw, 30px)\n clamp(16px, 5cqw, 32px);\n max-width: none;\n font-family: var(--font);\n font-size: clamp(0.8rem, 3.3cqw, 1.24rem);\n font-weight: 400;\n line-height: 1.8;\n letter-spacing: -0.006em;\n color: var(--ink);\n }\n\n .wires-rest {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n overflow: visible;\n z-index: 2;\n }\n\n /* The tether layer is the region's own markup and the resting threads are\n drawn from script, so neither carries this template's scope: both are\n reached through :deep(). */\n .wires :deep([data-choreo-tethers]) {\n z-index: 2;\n overflow: visible;\n }\n\n /* Live tethers still run — tests read them — but the ink you see is the\n resting path, retargeted every frame. Hiding rest here replayed a fade. */\n .wires :deep([data-choreo-tether]) {\n visibility: hidden;\n }\n\n .wires :deep([data-choreo-tether]),\n .wires :deep(.wires-thread) {\n fill: none;\n stroke-width: 1.15;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n .wires :deep(.wires-thread) {\n opacity: 0;\n transition: opacity 0.18s var(--ease);\n }\n\n .wires[data-hot='gauge'] :deep(.wires-thread[data-thread='gauge']),\n .wires[data-hot='edition'] :deep(.wires-thread[data-thread='edition']),\n .wires[data-hot='hed'] :deep(.wires-thread[data-thread='hed']) {\n opacity: 1;\n }\n\n .wires :deep([data-thread='gauge']) {\n --wires-ink: var(--copper-ink);\n --wires-wash: var(--copper);\n }\n\n .wires :deep([data-thread='edition']) {\n --wires-ink: var(--iris);\n --wires-wash: var(--iris);\n }\n\n .wires :deep([data-thread='hed']) {\n --wires-ink: var(--teal);\n --wires-wash: var(--teal);\n }\n\n .wires :deep([data-choreo-tether]),\n .wires :deep(.wires-thread) {\n stroke: var(--wires-ink);\n }\n\n .wires-mark {\n padding: 0.02em 0.1em;\n background: color-mix(in srgb, var(--wires-wash) 14%, transparent);\n color: inherit;\n font-style: normal;\n border-radius: 2px;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n cursor: pointer;\n outline: none;\n }\n\n .wires[data-hot='gauge'] .wires-mark[data-thread='gauge'],\n .wires[data-hot='edition'] .wires-mark[data-thread='edition'],\n .wires[data-hot='hed'] .wires-mark[data-thread='hed'] {\n background: color-mix(in srgb, var(--wires-wash) 24%, transparent);\n }\n\n .wires-margin {\n grid-column: 2;\n grid-row: 2;\n position: relative;\n z-index: 3;\n display: flex;\n flex-direction: column;\n gap: clamp(8px, 2.2cqw, 14px);\n margin: 0;\n padding: clamp(12px, 3.2cqw, 20px) 0 8px;\n list-style: none;\n }\n\n .wires-note {\n display: flex;\n align-items: stretch;\n gap: 10px;\n padding: 2px 2px 2px 0;\n border: 0;\n background: transparent;\n cursor: pointer;\n outline: none;\n }\n\n .wires-hue {\n width: 3px;\n flex: none;\n align-self: stretch;\n min-height: 22px;\n border-radius: 2px;\n background: var(--wires-ink);\n opacity: 0.45;\n transition: opacity 0.16s var(--ease);\n }\n\n .wires-note-copy {\n min-width: 0;\n flex: 1;\n padding: 1px 0 2px;\n opacity: 0.58;\n transition: opacity 0.16s var(--ease);\n }\n\n .wires[data-hot='gauge'] .wires-note[data-thread='gauge'] .wires-hue,\n .wires[data-hot='edition'] .wires-note[data-thread='edition'] .wires-hue,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] .wires-hue,\n .wires[data-hot='gauge']\n .wires-note[data-thread='gauge']\n .wires-note-copy,\n .wires[data-hot='edition']\n .wires-note[data-thread='edition']\n .wires-note-copy,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] .wires-note-copy {\n opacity: 1;\n }\n\n .wires-who {\n display: block;\n font-family: var(--font);\n font-size: clamp(10px, 2cqw, 13px);\n font-style: normal;\n font-weight: 600;\n letter-spacing: -0.01em;\n text-transform: none;\n color: var(--ink);\n }\n\n .wires-note p {\n margin: 4px 0 0;\n font-family: var(--font);\n font-size: clamp(10.5px, 2.2cqw, 14px);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n color: var(--ink-dim);\n }\n\n .wires[data-hot='gauge'] .wires-note[data-thread='gauge'] p,\n .wires[data-hot='edition'] .wires-note[data-thread='edition'] p,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] p {\n color: var(--ink);\n }\n\n @container (max-width: 460px) {\n .wires-board {\n grid-template-columns: minmax(0, 1fr);\n grid-template-rows: auto auto auto;\n row-gap: 10px;\n }\n\n /* the paper's own panel spans the note, not the comments below it */\n .wires-board::before {\n grid-column: 1;\n grid-row: 1 / span 2;\n }\n\n .wires-head {\n grid-column: 1;\n grid-row: 1;\n }\n\n .wires-copy {\n grid-column: 1;\n grid-row: 2;\n }\n\n .wires-margin {\n grid-column: 1;\n grid-row: 3;\n padding-top: 2px;\n }\n\n /* two comments side by side under the note reads as a caption row rather\n than as a second column of prose */\n .wires-margin {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 8px;\n }\n }\n\n @container (max-height: 240px) {\n .wires-head,\n .wires-bar-label {\n display: none;\n }\n\n .wires-copy {\n padding: 12px 14px;\n font-size: 0.78rem;\n line-height: 1.55;\n }\n\n .wires-margin {\n padding-top: 12px;\n gap: 6px;\n }\n\n .wires-note p {\n display: none;\n }\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSpring('wires', GLIDE, 'GLIDE');\ntuneSeconds('wires', 0.28, 'Step 1 duration');\ntuneSeconds('wires', 0.32, 'Step 2 duration');\n\nexport class WiresDemo extends GalleryDemo {\n static stage = Wires;\n static notes = WiresNotes;\n}\n";
558
+ var wires_default = "import { Choreo, type ChoreoRun, type Rect } from '@cardstack/choreo';\nimport { array, fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, spring } from 'glimmer-motion';\n\nimport { GalleryDemo } from '../demo';\nimport { observeStage } from '../lib/onstage';\nimport { tuneSeconds, tuneSpring } from '../lib/tuning';\nimport WiresNotes from '../notes/wires';\n\nconst GLIDE = spring({ damping: 28, stiffness: 240 });\n\n/**\n * Smooth, always. The lift fans overlapping cubics so three threads can\n * share a gap without an elbow.\n */\nconst curve =\n (lift = 0) =>\n (a: Rect, b: Rect) => {\n const x1 = a.x + a.width;\n const y1 = a.y + a.height / 2;\n const x2 = b.x;\n const y2 = b.y + b.height / 2;\n const mx = (x1 + x2) / 2;\n return `M ${x1} ${y1} C ${mx} ${y1 + lift}, ${mx} ${y2 + lift}, ${x2} ${y2}`;\n };\n\ntype ThreadId = 'edition' | 'gauge' | 'hed';\n\ninterface Piece {\n id?: string;\n key: string;\n text: string;\n thread?: ThreadId;\n}\n\ninterface Note {\n body: string;\n by: string;\n id: string;\n thread: ThreadId;\n}\n\ninterface Draft {\n copy: Piece[];\n notes: Note[];\n}\n\nconst PATH_GAUGE = curve(-10);\nconst PATH_EDITION = curve(0);\nconst PATH_HED = curve(12);\n\nconst THREADS: {\n from: string;\n path: (a: Rect, b: Rect) => string;\n thread: ThreadId;\n to: string;\n}[] = [\n { from: 'm-gauge', path: PATH_GAUGE, thread: 'gauge', to: 'c-gauge' },\n { from: 'm-edition', path: PATH_EDITION, thread: 'edition', to: 'c-edition' },\n { from: 'm-hed', path: PATH_HED, thread: 'hed', to: 'c-hed' },\n];\n\nconst DRAFTS: Draft[] = [\n {\n copy: [\n { key: 'a0', text: 'The river ' },\n { id: 'm-gauge', key: 'g', text: 'rose overnight', thread: 'gauge' },\n { key: 'a1', text: '. We hold the ' },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n { key: 'a2', text: ' for the levee update.' },\n ],\n notes: [\n {\n body: 'Confirm with the gauge at first light.',\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: 'City desk wants 800, not 400.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n ],\n },\n {\n copy: [\n { key: 'a0', text: 'The river ' },\n { id: 'm-gauge', key: 'g', text: 'rose overnight', thread: 'gauge' },\n {\n key: 'a1',\n text: ', and the lower ward is already sandbagging. We hold the ',\n },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n { key: 'a2', text: ' for the levee update, then lead with the ' },\n { id: 'm-hed', key: 'h', text: \"mayor's walk\", thread: 'hed' },\n { key: 'a3', text: '.' },\n ],\n notes: [\n {\n body: \"Gauge at 14.2. Don't say flood until they do.\",\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: '800. Keep the levee in the hed.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n {\n body: \"She hasn't confirmed. Hedge it.\",\n by: 'Jo',\n id: 'c-hed',\n thread: 'hed',\n },\n ],\n },\n {\n copy: [\n {\n key: 'a0',\n text: 'The river rose overnight, and the lower ward is already sandbagging. We hold the ',\n },\n { id: 'm-edition', key: 'e', text: 'morning edition', thread: 'edition' },\n {\n key: 'a2',\n text: \" for the levee update, then lead with the mayor's walk — \",\n },\n { id: 'm-hed', key: 'h', text: 'if she walks', thread: 'hed' },\n { key: 'a3', text: \". If she doesn't, \" },\n {\n id: 'm-gauge',\n key: 'g',\n text: 'the gauge is the story',\n thread: 'gauge',\n },\n { key: 'a4', text: '.' },\n ],\n notes: [\n {\n body: \"That's the hed. Play it.\",\n by: 'Maya',\n id: 'c-gauge',\n thread: 'gauge',\n },\n {\n body: '800 stands.',\n by: 'Ed',\n id: 'c-edition',\n thread: 'edition',\n },\n {\n body: 'Confirmed 7am. Drop the hedge.',\n by: 'Jo',\n id: 'c-hed',\n thread: 'hed',\n },\n ],\n },\n];\n\n/**\n * Resting ink — the same cubics the run draws. Paths are kept by thread id\n * so a connected wire is retargeted, not torn down and faded back in.\n */\nfunction paintRest(svg: SVGSVGElement) {\n const root = svg.closest('.wires');\n if (!root) {\n return;\n }\n // Client rects carry every ancestor transform — including the page\n // crossing, which is scaling this whole stage mid-flight while the\n // follow loop repaints. Subtracting raw client coordinates baked that\n // scale into the ink and the threads stood ~10px off their marks for\n // the whole flight; mapping through the svg's own screen CTM lands\n // them in user units no matter what is carrying the page this frame —\n // the same mapping, for the same reason, as the engine's drawTether.\n const inverse = svg.getScreenCTM()?.inverse();\n const R = root.getBoundingClientRect();\n const toLocal = (x: number, y: number) => {\n if (!inverse) {\n return { x: x - R.left, y: y - R.top };\n }\n const p = new DOMPoint(x, y).matrixTransform(inverse);\n return { x: p.x, y: p.y };\n };\n const box = (id: string): Rect | null => {\n const el = root.querySelector(`[data-node=\"${id}\"]`);\n if (!el) {\n return null;\n }\n const r = el.getBoundingClientRect();\n const tl = toLocal(r.left, r.top);\n const br = toLocal(r.right, r.bottom);\n return {\n height: br.y - tl.y,\n width: br.x - tl.x,\n x: tl.x,\n y: tl.y,\n };\n };\n const keep = new Set<string>();\n for (const thread of THREADS) {\n const a = box(thread.from);\n const b = box(thread.to);\n if (!a || !b) {\n continue;\n }\n keep.add(thread.thread);\n let path = svg.querySelector(\n `:scope > [data-thread=\"${thread.thread}\"]`,\n ) as SVGPathElement | null;\n if (!path) {\n path = document.createElementNS('http://www.w3.org/2000/svg', 'path');\n path.setAttribute('class', 'wires-thread');\n path.setAttribute('data-thread', thread.thread);\n svg.append(path);\n }\n path.setAttribute('d', thread.path(a, b));\n }\n for (const path of [...svg.querySelectorAll(':scope > path')]) {\n const id = path.getAttribute('data-thread');\n if (!id || !keep.has(id)) {\n path.remove();\n }\n }\n}\n\nconst eq = (a: number, b: number) => a === b;\nconst VERSIONS = [0, 1, 2];\nconst label = (v: number) => `V${v + 1}`;\nconst draftNo = (v: number) => String(v + 1);\n\n/**\n * A morning note, three drafts. Marks in the prose, comments in the margin;\n * the thread between them is not a tween. The first comment is selected on\n * load; hover or tap another pair to move the thread. Step V1 → V2 → V3 and\n * the copy grows, the comments update, and every wire is asked again on\n * every frame of the spring.\n */\nexport class Wires extends Component {\n @tracked version = 0;\n\n rest = modifier(\n (el: SVGSVGElement, [version, c]: [number, { run: ChoreoRun | null }]) => {\n void version;\n const root = el.closest('.wires');\n const layer = root?.querySelector('[data-choreo-tethers]');\n let follow = 0;\n const pin = () => {\n if (!root) {\n return;\n }\n const hot = root.getAttribute('data-hot');\n if (hot && root.querySelector(`[data-thread=\"${hot}\"]`)) {\n return;\n }\n const first = root.querySelector('.wires-note') as HTMLElement | null;\n root.setAttribute('data-hot', first?.dataset.thread ?? 'gauge');\n };\n const draw = () => {\n paintRest(el);\n pin();\n };\n // The tethers stand for as long as the board does, and a standing run\n // ticks, redrawing them, every frame; the rest wires follow it. Both run\n // only while someone can see them: scrolled away or covered, the run is\n // paused and the board holds still, and both carry on when it comes back.\n // A run starts by laying its tethers into the layer, so one that starts\n // off stage is paused as it lays them.\n let onStage = false;\n const followDraw = () => {\n draw();\n follow =\n onStage && root?.querySelector('[data-choreo-tether]')\n ? requestAnimationFrame(followDraw)\n : 0;\n };\n const startFollow = () => {\n if (!onStage) {\n c.run?.pause();\n } else if (!follow) {\n follow = requestAnimationFrame(followDraw);\n }\n };\n const stage = observeStage(root ?? el, (visible) => {\n onStage = visible;\n if (visible) {\n c.run?.play();\n startFollow();\n } else {\n c.run?.pause();\n cancelAnimationFrame(follow);\n follow = 0;\n }\n });\n const id = requestAnimationFrame(() => requestAnimationFrame(draw));\n const ro =\n root && typeof ResizeObserver !== 'undefined'\n ? new ResizeObserver(() => requestAnimationFrame(draw))\n : null;\n const mo =\n layer && typeof MutationObserver !== 'undefined'\n ? new MutationObserver(startFollow)\n : null;\n if (root) {\n ro?.observe(root);\n }\n if (layer) {\n mo?.observe(layer, { childList: true });\n }\n return () => {\n stage.disconnect();\n cancelAnimationFrame(id);\n cancelAnimationFrame(follow);\n ro?.disconnect();\n mo?.disconnect();\n };\n },\n );\n\n /**\n * Pair a mark with its comment — an attribute on the region, not a tracked\n * write. A tracked hot would re-render the parent and restart the run.\n * Selection sticks: the first comment is on by default; hover or tap\n * another pair to move the thread.\n */\n pair = modifier((el: HTMLElement) => {\n const root = () => el.closest('.wires');\n const id = el.dataset.thread ?? '';\n const select = () => root()?.setAttribute('data-hot', id);\n el.addEventListener('pointerenter', select);\n el.addEventListener('focus', select);\n el.addEventListener('click', select);\n return () => {\n el.removeEventListener('pointerenter', select);\n el.removeEventListener('focus', select);\n el.removeEventListener('click', select);\n };\n });\n\n get draft() {\n return DRAFTS[this.version]!;\n }\n\n go = (version: number) => {\n this.version = version;\n };\n\n isMark = (part: Piece) => Boolean(part.id);\n\n <template>\n <div class='ex'>\n <div class='wires-bar' role='group' aria-label='Draft'>\n <span class='wires-bar-label'>Draft</span>\n {{#each VERSIONS as |v|}}\n <button\n type='button'\n class={{if (eq v this.version) 'is-on'}}\n aria-pressed='{{eq v this.version}}'\n data-test-wires-v={{label v}}\n {{on 'click' (fn this.go v)}}\n >{{draftNo v}}</button>\n {{/each}}\n </div>\n\n <Choreo class='wires' data-hot='gauge' data-test-wires as |c|>\n <svg\n class='wires-rest'\n aria-hidden='true'\n {{this.rest this.version c}}\n ></svg>\n\n <div class='wires-board'>\n <article class='wires-paper'>\n <header class='wires-head'>\n <p class='wires-kicker'>\n <span>City desk</span>\n <time datetime='06:12'>6:12 a.m.</time>\n </p>\n <h2 class='wires-hed'>Morning note</h2>\n </header>\n <p class='wires-copy'>\n {{#each this.draft.copy key='key' as |part|}}\n {{#if (this.isMark part)}}\n <mark\n class='wires-mark'\n data-node={{part.id}}\n data-thread={{part.thread}}\n data-test-wires-mark={{part.thread}}\n tabindex='0'\n {{motion id=part.id role='mark'}}\n {{this.pair}}\n >{{part.text}}</mark>\n {{else}}\n {{part.text}}\n {{/if}}\n {{/each}}\n </p>\n </article>\n\n <ol class='wires-margin' aria-label='Comments on this draft'>\n {{#each this.draft.notes key='id' as |note|}}\n <li\n class='wires-note'\n data-node={{note.id}}\n data-thread={{note.thread}}\n data-test-wires-note={{note.thread}}\n tabindex='0'\n {{motion id=note.id role='note'}}\n {{this.pair}}\n >\n <span class='wires-hue' aria-hidden='true'></span>\n <span class='wires-note-copy'>\n <span class='wires-who'>{{note.by}}</span>\n <p>{{note.body}}</p>\n </span>\n </li>\n {{/each}}\n </ol>\n </div>\n\n <c.Parallel>\n <c.Hold @of={{c.role 'note'}} @zIndex={{3}} />\n <c.Move\n @of={{c.moved 'mark'}}\n @spring={{tuneSpring 'wires' GLIDE 'GLIDE'}}\n @size={{false}}\n />\n <c.Move\n @of={{c.moved 'note'}}\n @spring={{tuneSpring 'wires' GLIDE 'GLIDE'}}\n />\n <c.Tween\n @of={{c.removed 'note'}}\n @opacity={{0}}\n @y={{-8}}\n @duration={{tuneSeconds 'wires' 0.28 'Step 1 duration'}}\n @ease='easeOut'\n />\n <c.Tween\n @of={{c.inserted 'note'}}\n @opacity={{array 0 1}}\n @y={{array 8 0}}\n @duration={{tuneSeconds 'wires' 0.32 'Step 2 duration'}}\n @ease='easeOut'\n />\n <c.Tether\n @name='gauge'\n @from={{c.id 'm-gauge'}}\n @to={{c.id 'c-gauge'}}\n @path={{PATH_GAUGE}}\n />\n <c.Tether\n @name='edition'\n @from={{c.id 'm-edition'}}\n @to={{c.id 'c-edition'}}\n @path={{PATH_EDITION}}\n />\n <c.Tether\n @name='hed'\n @from={{c.id 'm-hed'}}\n @to={{c.id 'c-hed'}}\n @path={{PATH_HED}}\n />\n </c.Parallel>\n </Choreo>\n </div>\n <style scoped>\n .ex {\n position: absolute;\n inset: 0;\n display: grid;\n place-items: center;\n width: 100%;\n max-width: 100%;\n /* every stage keeps air on all four sides. A demo that runs edge to edge\n reads as a layout bug rather than as a stage, and the ones sized\n `min(Npx, 100%)` hit the frame exactly when the card is narrow.\n The block padding matters as much as the inline: on a stage tall\n enough to fill the platter, content flush against the top and bottom\n of the recess reads as overflowed rather than placed. */\n padding-block: 10px;\n padding-inline: 16px;\n overflow: hidden;\n container-type: size;\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n -webkit-user-drag: none;\n }\n\n /* ---- Wires: comments that keep their hold ---- */\n\n .ex:has(.wires) {\n padding-block: 20px;\n -webkit-user-select: text;\n user-select: text;\n }\n\n .wires-bar {\n position: absolute;\n top: 14px;\n left: 50%;\n z-index: 4;\n display: inline-flex;\n align-items: center;\n gap: 2px;\n padding: 3px 4px 3px 12px;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--bg-elev);\n transform: translateX(-50%);\n }\n\n .wires-bar-label {\n margin-right: 6px;\n font-family: var(--font);\n font-size: 11px;\n font-weight: 500;\n letter-spacing: 0.01em;\n color: var(--ink-faint);\n }\n\n .wires-bar button {\n border: 0;\n background: transparent;\n border-radius: 999px;\n min-width: 26px;\n padding: 4px 9px;\n font-family: var(--font);\n font-size: 12px;\n font-weight: 500;\n font-variant-numeric: tabular-nums;\n color: var(--ink-faint);\n }\n\n @media (hover: hover) {\n .wires-bar button:hover {\n color: var(--ink);\n }\n }\n\n .wires-bar button.is-on {\n background: color-mix(in srgb, var(--ink) 8%, transparent);\n color: var(--ink);\n }\n\n .wires {\n position: relative;\n width: min(90cqw, 640px);\n padding: 44px 6px 8px;\n overflow: visible;\n /* inline-size only: a size container with auto height reports 0 block\n size, so the compact query would always match on the demo page. */\n container-type: inline-size;\n }\n\n .wires-board {\n display: grid;\n grid-template-columns: minmax(0, 1.4fr) minmax(148px, 0.68fr);\n grid-template-rows: auto 1fr;\n column-gap: 24px;\n }\n\n .wires-board::before {\n content: '';\n grid-column: 1;\n grid-row: 1 / span 2;\n border: 1px solid var(--line);\n border-radius: 4px;\n background: var(--bg-spot);\n pointer-events: none;\n }\n\n .wires-paper {\n display: contents;\n }\n\n .wires-head {\n grid-column: 1;\n grid-row: 1;\n z-index: 1;\n min-width: 0;\n margin: 0;\n padding: clamp(14px, 4cqw, 26px) clamp(16px, 4.7cqw, 30px)\n clamp(8px, 2.2cqw, 14px);\n border-bottom: 1px solid var(--line);\n }\n\n .wires-kicker {\n display: flex;\n justify-content: space-between;\n align-items: baseline;\n gap: 12px;\n margin: 0 0 10px;\n font-family: var(--font);\n font-size: 11px;\n font-weight: 500;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--ink-faint);\n }\n\n .wires-hed {\n margin: 0;\n font-family: var(--font-display);\n font-size: clamp(1rem, 4.8cqw, 1.9rem);\n font-weight: 700;\n letter-spacing: -0.035em;\n line-height: 1.12;\n color: var(--ink);\n }\n\n .wires-copy {\n grid-column: 1;\n grid-row: 2;\n z-index: 1;\n min-width: 0;\n margin: 0;\n padding: clamp(12px, 3.2cqw, 20px) clamp(16px, 4.7cqw, 30px)\n clamp(16px, 5cqw, 32px);\n max-width: none;\n font-family: var(--font);\n font-size: clamp(0.8rem, 3.3cqw, 1.24rem);\n font-weight: 400;\n line-height: 1.8;\n letter-spacing: -0.006em;\n color: var(--ink);\n }\n\n .wires-rest {\n position: absolute;\n inset: 0;\n width: 100%;\n height: 100%;\n pointer-events: none;\n overflow: visible;\n z-index: 2;\n }\n\n /* The tether layer is the region's own markup and the resting threads are\n drawn from script, so neither carries this template's scope: both are\n reached through :deep(). */\n .wires :deep([data-choreo-tethers]) {\n z-index: 2;\n overflow: visible;\n }\n\n /* Live tethers still run — tests read them — but the ink you see is the\n resting path, retargeted every frame. Hiding rest here replayed a fade. */\n .wires :deep([data-choreo-tether]) {\n visibility: hidden;\n }\n\n .wires :deep([data-choreo-tether]),\n .wires :deep(.wires-thread) {\n fill: none;\n stroke-width: 1.15;\n stroke-linecap: round;\n stroke-linejoin: round;\n }\n\n .wires :deep(.wires-thread) {\n opacity: 0;\n transition: opacity 0.18s var(--ease);\n }\n\n .wires[data-hot='gauge'] :deep(.wires-thread[data-thread='gauge']),\n .wires[data-hot='edition'] :deep(.wires-thread[data-thread='edition']),\n .wires[data-hot='hed'] :deep(.wires-thread[data-thread='hed']) {\n opacity: 1;\n }\n\n .wires :deep([data-thread='gauge']) {\n --wires-ink: var(--copper-ink);\n --wires-wash: var(--copper);\n }\n\n .wires :deep([data-thread='edition']) {\n --wires-ink: var(--iris);\n --wires-wash: var(--iris);\n }\n\n .wires :deep([data-thread='hed']) {\n --wires-ink: var(--teal);\n --wires-wash: var(--teal);\n }\n\n .wires :deep([data-choreo-tether]),\n .wires :deep(.wires-thread) {\n stroke: var(--wires-ink);\n }\n\n .wires-mark {\n padding: 0.02em 0.1em;\n background: color-mix(in srgb, var(--wires-wash) 14%, transparent);\n color: inherit;\n font-style: normal;\n border-radius: 2px;\n box-decoration-break: clone;\n -webkit-box-decoration-break: clone;\n cursor: pointer;\n }\n\n .wires[data-hot='gauge'] .wires-mark[data-thread='gauge'],\n .wires[data-hot='edition'] .wires-mark[data-thread='edition'],\n .wires[data-hot='hed'] .wires-mark[data-thread='hed'] {\n background: color-mix(in srgb, var(--wires-wash) 24%, transparent);\n }\n\n .wires-margin {\n grid-column: 2;\n grid-row: 2;\n position: relative;\n z-index: 3;\n display: flex;\n flex-direction: column;\n gap: clamp(8px, 2.2cqw, 14px);\n margin: 0;\n padding: clamp(12px, 3.2cqw, 20px) 0 8px;\n list-style: none;\n }\n\n .wires-note {\n display: flex;\n align-items: stretch;\n gap: 10px;\n padding: 2px 2px 2px 0;\n border: 0;\n background: transparent;\n cursor: pointer;\n }\n\n .wires-hue {\n width: 3px;\n flex: none;\n align-self: stretch;\n min-height: 22px;\n border-radius: 2px;\n background: var(--wires-ink);\n opacity: 0.45;\n transition: opacity 0.16s var(--ease);\n }\n\n .wires-note-copy {\n min-width: 0;\n flex: 1;\n padding: 1px 0 2px;\n opacity: 0.58;\n transition: opacity 0.16s var(--ease);\n }\n\n .wires[data-hot='gauge'] .wires-note[data-thread='gauge'] .wires-hue,\n .wires[data-hot='edition'] .wires-note[data-thread='edition'] .wires-hue,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] .wires-hue,\n .wires[data-hot='gauge']\n .wires-note[data-thread='gauge']\n .wires-note-copy,\n .wires[data-hot='edition']\n .wires-note[data-thread='edition']\n .wires-note-copy,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] .wires-note-copy {\n opacity: 1;\n }\n\n .wires-who {\n display: block;\n font-family: var(--font);\n font-size: clamp(10px, 2cqw, 13px);\n font-style: normal;\n font-weight: 600;\n letter-spacing: -0.01em;\n text-transform: none;\n color: var(--ink);\n }\n\n .wires-note p {\n margin: 4px 0 0;\n font-family: var(--font);\n font-size: clamp(10.5px, 2.2cqw, 14px);\n font-style: normal;\n font-weight: 400;\n line-height: 1.5;\n color: var(--ink-dim);\n }\n\n .wires[data-hot='gauge'] .wires-note[data-thread='gauge'] p,\n .wires[data-hot='edition'] .wires-note[data-thread='edition'] p,\n .wires[data-hot='hed'] .wires-note[data-thread='hed'] p {\n color: var(--ink);\n }\n\n @container (max-width: 460px) {\n .wires-board {\n grid-template-columns: minmax(0, 1fr);\n grid-template-rows: auto auto auto;\n row-gap: 10px;\n }\n\n /* the paper's own panel spans the note, not the comments below it */\n .wires-board::before {\n grid-column: 1;\n grid-row: 1 / span 2;\n }\n\n .wires-head {\n grid-column: 1;\n grid-row: 1;\n }\n\n .wires-copy {\n grid-column: 1;\n grid-row: 2;\n }\n\n .wires-margin {\n grid-column: 1;\n grid-row: 3;\n padding-top: 2px;\n }\n\n /* two comments side by side under the note reads as a caption row rather\n than as a second column of prose */\n .wires-margin {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 8px;\n }\n }\n\n @container (max-height: 240px) {\n .wires-head,\n .wires-bar-label {\n display: none;\n }\n\n .wires-copy {\n padding: 12px 14px;\n font-size: 0.78rem;\n line-height: 1.55;\n }\n\n .wires-margin {\n padding-top: 12px;\n gap: 6px;\n }\n\n .wires-note p {\n display: none;\n }\n }\n </style>\n </template>\n}\n\n// Declare the demo variables before the first interactive Choreo pass.\ntuneSpring('wires', GLIDE, 'GLIDE');\ntuneSeconds('wires', 0.28, 'Step 1 duration');\ntuneSeconds('wires', 0.32, 'Step 2 duration');\n\nexport class WiresDemo extends GalleryDemo {\n static stage = Wires;\n static notes = WiresNotes;\n}\n";
559
559
  //#endregion
560
560
  //#region ../choreo-gallery/realm/studies/recordable-scene.gts?raw
561
561
  var recordable_scene_default$1 = "import { Choreo, type ChoreoContext } from '@cardstack/choreo';\nimport { createChoreoPlayer } from '@cardstack/choreo-player';\nimport { on } from '@ember/modifier';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { modifier } from 'ember-modifier';\nimport { motion, styles } from 'glimmer-motion';\n\nimport { GuideStudy } from '../study';\n\nexport class RecordableScene extends Component {\n @tracked armed = false;\n @tracked requested = 0;\n private context?: ChoreoContext;\n private disposed = false;\n private player = createChoreoPlayer({\n duration: 6,\n prepare: async () => {\n this.armed = true;\n const deadline = performance.now() + 5000;\n // c.run is a live getter, not tracked state. Observe publication after\n // the render pass; this loop waits for readiness, it does not sequence motion.\n while (!this.context?.run && !this.disposed) {\n if (performance.now() > deadline) {\n throw new Error('The scene did not publish a run');\n }\n await new Promise<void>((resolve) =>\n requestAnimationFrame(() => resolve()),\n );\n }\n },\n runs: () => (this.context?.run ? [this.context.run] : []),\n });\n @tracked distance = 240;\n get positions() {\n return [0, this.distance, this.distance, 0];\n }\n scales = [1, 1.5, 0.8, 1];\n times = [0, 0.35, 0.65, 1];\n bind = modifier((_el: HTMLElement, [context]: [ChoreoContext]) => {\n this.context = context;\n });\n renderAt = async (time: number) => {\n await this.player.renderAt(time);\n };\n play = () => {\n void this.player.play();\n };\n pause = () => {\n this.player.pause();\n };\n seek = (event: Event) => {\n this.requested = Number((event.target as HTMLInputElement).value);\n void this.renderAt(this.requested);\n };\n expose = modifier((el: HTMLElement) => {\n this.distance = el.clientWidth * 0.6;\n const capture = { renderAt: this.renderAt, duration: 6 };\n Object.assign(el, { tutorialCapture: capture });\n return () => {\n this.disposed = true;\n this.player.pause();\n Reflect.deleteProperty(el, 'tutorialCapture');\n };\n });\n <template>\n <section class='tutorial-film' aria-label='Recordable scene tutorial'>\n <div class='tutorial-film-controls'><button\n type='button'\n {{on 'click' this.play}}\n >Play scene</button>\n <button type='button' {{on 'click' this.pause}}>Pause scene</button>\n <label>Seek (seconds)\n <input\n type='range'\n min='0'\n max='6'\n step='0.1'\n value={{this.requested}}\n {{on 'input' this.seek}}\n /></label></div>\n <div class='tutorial-film-frame' data-tutorial-film {{this.expose}}>\n <h2>A scene is a function of time.</h2>\n <p>One cast. One score. Any frame.</p>\n <Choreo as |c|>\n <div class='tutorial-film-stage' {{this.bind c}}>\n <div\n class='tutorial-film-dot'\n {{motion id='dot' style=(styles borderRadius='50%')}}\n ></div>\n </div>\n {{#if this.armed}}\n <c.Parallel>\n <c.Tween\n @of={{c.id 'dot'}}\n @x={{this.positions}}\n @times={{this.times}}\n @duration={{6}}\n @ease='linear'\n />\n <c.Tween\n @of={{c.id 'dot'}}\n @scale={{this.scales}}\n @times={{this.times}}\n @duration={{6}}\n @ease='linear'\n />\n </c.Parallel>\n {{/if}}\n </Choreo>\n <small>CHOREO / A SIX-SECOND STUDY</small>\n </div>\n </section>\n <style scoped>\n .tutorial-film {\n font: 16px/1.5 system-ui;\n color: #f3eadf;\n color-scheme: dark;\n box-shadow: 0 0 0 1px #514337;\n border-radius: 12px 12px 0 0;\n }\n .tutorial-film-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n background: #302820;\n border-radius: 12px 12px 0 0;\n border-bottom: 1px solid #514337;\n }\n .tutorial-film-controls button {\n appearance: none;\n font: 500 13px/1.2 system-ui;\n color: #f3eadf;\n background: #44382e;\n border: 1px solid #79614e;\n border-radius: 7px;\n min-height: 36px;\n padding: 8px 12px;\n cursor: pointer;\n }\n .tutorial-film-controls button:hover {\n background: #584536;\n border-color: #e89866;\n }\n .tutorial-film-controls button:active {\n background: #302820;\n }\n .tutorial-film-controls button:focus-visible,\n .tutorial-film-controls input:focus-visible {\n outline: 2px solid #ff9b65;\n outline-offset: 3px;\n }\n .tutorial-film-controls label {\n display: flex;\n align-items: center;\n flex: 1 1 200px;\n gap: 10px;\n margin: 0;\n padding: 0 4px;\n font: 500 13px/1.2 system-ui;\n color: #d9c8b8;\n }\n .tutorial-film-controls input[type='range'] {\n flex: 1;\n min-width: 80px;\n width: 100%;\n height: 24px;\n margin: 0;\n accent-color: #ff8755;\n cursor: pointer;\n }\n @media (pointer: coarse) {\n .tutorial-film-controls button {\n min-height: 44px;\n }\n .tutorial-film-controls input[type='range'] {\n height: 44px;\n }\n }\n .tutorial-film-frame {\n width: 100%;\n aspect-ratio: 16 / 9;\n position: relative;\n overflow: hidden;\n background: #241e19;\n container-type: inline-size;\n }\n .tutorial-film-frame h2 {\n margin: 0;\n position: absolute;\n left: 8%;\n top: 12%;\n font-size: 4.4cqw;\n }\n .tutorial-film-frame p {\n position: absolute;\n left: 8%;\n top: 25%;\n font-size: 2.3cqw;\n }\n .tutorial-film-stage {\n position: relative;\n height: 56.25cqw;\n width: 100%;\n }\n .tutorial-film-dot {\n position: absolute;\n left: 12%;\n top: 52%;\n width: 8cqw;\n height: 8cqw;\n background: #ff6a3a;\n }\n .tutorial-film-frame small {\n position: absolute;\n bottom: 8%;\n left: 8%;\n font-size: 1.6cqw;\n letter-spacing: 0.12em;\n }\n </style>\n </template>\n}\n\nexport class RecordableSceneStudy extends GuideStudy {\n static study = RecordableScene;\n}\n";