@conduction/docusaurus-preset 3.38.0 → 3.39.0

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 (37) hide show
  1. package/MISSING_COMPONENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/no-icu-messages.test.js +57 -0
  4. package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
  5. package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
  6. package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
  7. package/src/components/BlueprintRush/engine.js +172 -0
  8. package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
  9. package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
  10. package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
  11. package/src/components/DeadlineDefender/engine.js +188 -0
  12. package/src/components/DetailHero/DetailHero.jsx +11 -4
  13. package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
  14. package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
  15. package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
  16. package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
  17. package/src/components/GameModal/GameModal.jsx +255 -50
  18. package/src/components/GameModal/GameModal.module.css +103 -0
  19. package/src/components/GameModal/__tests__/scores.test.js +123 -0
  20. package/src/components/GameModal/__tests__/share.test.js +83 -0
  21. package/src/components/GameModal/scores.js +149 -0
  22. package/src/components/GameModal/share.js +97 -0
  23. package/src/components/RecordRun/RecordRun.jsx +245 -0
  24. package/src/components/RecordRun/RecordRun.module.css +208 -0
  25. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  26. package/src/components/RecordRun/engine.js +180 -0
  27. package/src/components/StampRush/StampRush.jsx +232 -0
  28. package/src/components/StampRush/StampRush.module.css +188 -0
  29. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  30. package/src/components/StampRush/engine.js +185 -0
  31. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  32. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  33. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  34. package/src/components/index.js +5 -0
  35. package/src/data/app-downloads.js +21 -0
  36. package/src/index.js +10 -0
  37. package/src/theme/Footer/index.jsx +10 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * engine.test.js — the stamp-rush rules.
3
+ *
4
+ * The rule worth protecting is the asymmetry: adopting a decision that
5
+ * has no quorum costs you, and holding one back costs you nothing. A
6
+ * game that scored both the same would teach the opposite of what the
7
+ * app does.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const test = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+
15
+ const {
16
+ createGame, step, stamp, tempo, summarise, SLOTS, READY, NO_QUORUM, CONFLICT, DEFAULTS,
17
+ } = require('../engine.js');
18
+
19
+ /** Run the clock forward in ticks, so spawns and expiries both fire. */
20
+ function run(state, fromMs, toMs, tick = 60) {
21
+ let s = state;
22
+ for (let t = fromMs; t <= toMs; t += tick) s = step(s, t);
23
+ return s;
24
+ }
25
+
26
+ function put(state, slot, kind, now = 0, lifeMs = 1000) {
27
+ const slots = [...state.slots];
28
+ slots[slot] = {kind, id: `x${slot}`, bornAt: now, expiresAt: now + lifeMs, quorum: {have: 7, need: 7}};
29
+ return {...state, slots};
30
+ }
31
+
32
+ test('a new game starts empty, alive and scoreless', () => {
33
+ const s = createGame({seed: 1});
34
+ assert.equal(s.slots.length, SLOTS);
35
+ assert.equal(s.slots.filter(Boolean).length, 0);
36
+ assert.equal(s.lives, DEFAULTS.lives);
37
+ assert.equal(s.score, 0);
38
+ assert.equal(s.over, false);
39
+ });
40
+
41
+ test('cards appear over time, and never two in one slot', () => {
42
+ let s = run(createGame({seed: 3}), 0, 6000);
43
+ const filled = s.slots.filter(Boolean);
44
+ assert.ok(filled.length > 0, 'nothing ever spawned');
45
+ assert.ok(filled.length <= SLOTS);
46
+ const ids = new Set(filled.map((c) => c.id));
47
+ assert.equal(ids.size, filled.length);
48
+ });
49
+
50
+ test('adopting a ready decision scores, and a streak pays more each time', () => {
51
+ let s = createGame({seed: 5});
52
+ s = put(s, 0, READY);
53
+ s = stamp(s, 0, 10);
54
+ const first = s.score;
55
+ assert.equal(first, DEFAULTS.pointsPerAdopt);
56
+ assert.equal(s.adopted, 1);
57
+ assert.equal(s.combo, 1);
58
+
59
+ s = put(s, 1, READY);
60
+ s = stamp(s, 1, 20);
61
+ assert.equal(s.score - first, DEFAULTS.pointsPerAdopt + DEFAULTS.comboBonus);
62
+ assert.equal(s.bestCombo, 2);
63
+ });
64
+
65
+ test('stamping a decision without quorum costs a life and breaks the streak', () => {
66
+ let s = createGame({seed: 5});
67
+ s = put(s, 0, READY);
68
+ s = stamp(s, 0, 10);
69
+ s = put(s, 1, NO_QUORUM);
70
+ const before = s.score;
71
+ s = stamp(s, 1, 20);
72
+ assert.equal(s.lives, DEFAULTS.lives - 1);
73
+ assert.equal(s.combo, 0);
74
+ assert.equal(s.score, before, 'a mistake must not also pay');
75
+ assert.equal(s.mistakes, 1);
76
+ });
77
+
78
+ test('a conflict of interest is judged exactly like a missing quorum', () => {
79
+ let s = stamp(put(createGame({seed: 5}), 2, CONFLICT), 2, 10);
80
+ assert.equal(s.lives, DEFAULTS.lives - 1);
81
+ });
82
+
83
+ test('holding a bad decision back costs nothing, and is counted', () => {
84
+ let s = put(createGame({seed: 5}), 3, NO_QUORUM, 0, 500);
85
+ s = step(s, 600);
86
+ assert.equal(s.lives, DEFAULTS.lives, 'restraint was punished');
87
+ assert.equal(s.held, 1);
88
+ assert.equal(s.slots[3], null);
89
+ });
90
+
91
+ test('letting a ready decision expire costs a life', () => {
92
+ let s = put(createGame({seed: 5}), 4, READY, 0, 500);
93
+ s = step(s, 600);
94
+ assert.equal(s.lives, DEFAULTS.lives - 1);
95
+ assert.equal(s.slots[4], null);
96
+ });
97
+
98
+ test('three mistakes end the run, and nothing scores after it', () => {
99
+ let s = createGame({seed: 5});
100
+ for (let i = 0; i < 3; i++) {
101
+ s = put(s, i, NO_QUORUM);
102
+ s = stamp(s, i, 10 * i);
103
+ }
104
+ assert.equal(s.over, true);
105
+ assert.equal(s.lives, 0);
106
+
107
+ const after = stamp(put({...s, over: true}, 5, READY), 5, 99);
108
+ assert.equal(after.score, s.score, 'the board kept scoring after game over');
109
+ });
110
+
111
+ test('stamping an empty slot is not a mistake', () => {
112
+ const s = createGame({seed: 5});
113
+ const after = stamp(s, 2, 10);
114
+ assert.equal(after.lives, s.lives);
115
+ assert.equal(after.score, s.score);
116
+ });
117
+
118
+ test('the board speeds up with the score, down to a floor a person can still read', () => {
119
+ const slow = tempo(createGame({seed: 1}));
120
+ const mid = tempo({...createGame({seed: 1}), score: 400});
121
+ assert.ok(mid.spawnMs < slow.spawnMs, 'the game never got harder');
122
+ assert.ok(mid.lifeMs < slow.lifeMs);
123
+ /* 400 points is a good run, not an endless one, and the curve is
124
+ deliberately still short of its floors there: the floors are the
125
+ end of the ramp, not the middle of it. */
126
+ assert.ok(mid.spawnMs > DEFAULTS.spawnFloorMs);
127
+ assert.ok(mid.lifeMs > DEFAULTS.lifeFloorMs);
128
+
129
+ const relentless = tempo({...createGame({seed: 1}), score: 5000});
130
+ assert.equal(relentless.spawnMs, DEFAULTS.spawnFloorMs, 'the spawn rate never clamps');
131
+ assert.equal(relentless.lifeMs, DEFAULTS.lifeFloorMs, 'card lifetime never clamps');
132
+ });
133
+
134
+ test('a full board does not skip its next spawn once a slot frees up', () => {
135
+ let s = createGame({seed: 9, now: 0});
136
+ for (let i = 0; i < SLOTS; i++) s = put(s, i, READY, 0, 100000);
137
+ s = step(s, 5000);
138
+ assert.equal(s.slots.filter(Boolean).length, SLOTS);
139
+ s = stamp(s, 0, 5001);
140
+ s = step(s, 5200);
141
+ assert.ok(s.slots[0], 'the freed slot stayed empty');
142
+ });
143
+
144
+ test('the same seed plays the same game, a different one does not', () => {
145
+ const a = run(createGame({seed: 42}), 0, 4000);
146
+ const b = run(createGame({seed: 42}), 0, 4000);
147
+ const c = run(createGame({seed: 43}), 0, 4000);
148
+ const shape = (s) => s.slots.map((x) => (x ? x.kind : '-')).join(',');
149
+ assert.equal(shape(a), shape(b));
150
+ assert.notEqual(shape(a), shape(c));
151
+ });
152
+
153
+ test('both kinds of decision actually turn up over a long run', () => {
154
+ let s = createGame({seed: 11});
155
+ const seen = new Set();
156
+ for (let t = 0; t < 60000; t += 50) {
157
+ s = step(s, t);
158
+ s.slots.forEach((c) => { if (c) seen.add(c.kind); });
159
+ /* Keep it alive: adopt what is ready, hold the rest. */
160
+ s.slots.forEach((c, i) => { if (c && c.kind === READY) s = stamp(s, i, t); });
161
+ if (s.over) break;
162
+ }
163
+ assert.ok(seen.has(READY));
164
+ assert.ok(seen.has(NO_QUORUM) || seen.has(CONFLICT));
165
+ });
166
+
167
+ test('a perfect player is never punished by the clock', () => {
168
+ let s = createGame({seed: 21});
169
+ for (let t = 0; t < 30000; t += 40) {
170
+ s = step(s, t);
171
+ s.slots.forEach((c, i) => { if (c && c.kind === READY) s = stamp(s, i, t); });
172
+ }
173
+ assert.equal(s.over, false, 'playing correctly still ended the run');
174
+ assert.equal(s.lives, DEFAULTS.lives);
175
+ assert.ok(s.score > 0);
176
+ });
177
+
178
+ test('the summary reads as a sentence in both locales', () => {
179
+ const s = {adopted: 12, bestCombo: 5};
180
+ assert.match(summarise(s, 'en'), /12 decisions adopted · streak 5/);
181
+ assert.match(summarise(s, 'nl'), /12 besluiten vastgesteld · reeks 5/);
182
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Stamp rush — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Decidiq's game. Decisions land on your desk faster than you can read
5
+ * them, and the whole job is telling apart the ones you may adopt from
6
+ * the ones you may not. Stamping a decision that has no quorum, or one
7
+ * you should have declared an interest in, is the mistake the app
8
+ * exists to prevent, so here it costs you.
9
+ *
10
+ * Time and randomness are injected. The component owns the clock; this
11
+ * file owns the rules, which is what makes them testable and what
12
+ * keeps the arcade from being the only place they are written down.
13
+ *
14
+ * let s = createGame({seed: 7});
15
+ * s = step(s, now); // expire what is stale, spawn what is due
16
+ * s = stamp(s, slotIndex, now);
17
+ */
18
+
19
+ export const SLOTS = 6;
20
+
21
+ /* Card kinds. Only `ready` may be stamped; the other two are the
22
+ decisions a secretary is supposed to hold back. */
23
+ export const READY = 'ready';
24
+ export const NO_QUORUM = 'noQuorum';
25
+ export const CONFLICT = 'conflict';
26
+
27
+ export const DEFAULTS = {
28
+ lives: 3,
29
+ /* Spawn cadence and card lifetime both tighten as the score climbs,
30
+ which is the whole difficulty curve. The floors are what a person
31
+ can still react to: under about 400ms between cards the board
32
+ stops being readable, and a card that lives under 900ms cannot be
33
+ read before it has to be judged. */
34
+ spawnStartMs: 1100,
35
+ spawnFloorMs: 420,
36
+ lifeStartMs: 1900,
37
+ lifeFloorMs: 900,
38
+ rampPerPoint: 1.4,
39
+ /* Two in five cards must be held back. Fewer and the game is a
40
+ clicking exercise; more and it is mostly waiting. */
41
+ badShare: 0.4,
42
+ pointsPerAdopt: 10,
43
+ comboBonus: 2,
44
+ };
45
+
46
+ /* A small deterministic generator, so a test can pin a run and a
47
+ player still gets a different board every time. */
48
+ function mulberry32(seed) {
49
+ let a = seed >>> 0;
50
+ return function random() {
51
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
52
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
53
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
54
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
55
+ };
56
+ }
57
+
58
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
59
+ const cfg = {...DEFAULTS, ...config};
60
+ return {
61
+ cfg,
62
+ random: mulberry32(seed),
63
+ slots: Array.from({length: SLOTS}, () => null),
64
+ score: 0,
65
+ lives: cfg.lives,
66
+ combo: 0,
67
+ bestCombo: 0,
68
+ adopted: 0,
69
+ held: 0,
70
+ mistakes: 0,
71
+ startedAt: now,
72
+ nextSpawnAt: now + cfg.spawnStartMs,
73
+ over: false,
74
+ };
75
+ }
76
+
77
+ /** Interval and lifetime at the current score. */
78
+ export function tempo(state) {
79
+ const {cfg, score} = state;
80
+ const shed = score * cfg.rampPerPoint;
81
+ return {
82
+ spawnMs: Math.max(cfg.spawnFloorMs, cfg.spawnStartMs - shed),
83
+ lifeMs: Math.max(cfg.lifeFloorMs, cfg.lifeStartMs - shed),
84
+ };
85
+ }
86
+
87
+ function loseLife(state) {
88
+ const lives = state.lives - 1;
89
+ return {...state, lives, combo: 0, mistakes: state.mistakes + 1, over: lives <= 0};
90
+ }
91
+
92
+ /**
93
+ * Advance the board to `now`: expire cards whose time is up, then
94
+ * spawn at most one card per call into a free slot.
95
+ *
96
+ * Letting a ready decision expire costs a life. Letting one of the
97
+ * others expire is the correct call and costs nothing, which is the
98
+ * only way the game can reward restraint.
99
+ */
100
+ export function step(state, now) {
101
+ if (state.over) return state;
102
+ let next = {...state, slots: [...state.slots]};
103
+
104
+ for (let i = 0; i < next.slots.length; i++) {
105
+ const card = next.slots[i];
106
+ if (!card || now < card.expiresAt) continue;
107
+ next.slots[i] = null;
108
+ if (card.kind === READY) {
109
+ next = {...loseLife(next), slots: next.slots};
110
+ if (next.over) return next;
111
+ } else {
112
+ next.held = next.held + 1;
113
+ }
114
+ }
115
+
116
+ if (now >= next.nextSpawnAt) {
117
+ const free = [];
118
+ for (let i = 0; i < next.slots.length; i++) if (!next.slots[i]) free.push(i);
119
+ if (free.length) {
120
+ const {spawnMs, lifeMs} = tempo(next);
121
+ const slot = free[Math.floor(next.random() * free.length)];
122
+ const roll = next.random();
123
+ const kind = roll < next.cfg.badShare
124
+ ? (roll < next.cfg.badShare / 2 ? NO_QUORUM : CONFLICT)
125
+ : READY;
126
+ next.slots[slot] = {
127
+ kind,
128
+ id: `${slot}-${Math.round(now)}`,
129
+ bornAt: now,
130
+ expiresAt: now + lifeMs,
131
+ /* Shown on the card so the player has something to read
132
+ rather than a colour to memorise. */
133
+ quorum: kind === NO_QUORUM
134
+ ? {have: 2 + Math.floor(next.random() * 3), need: 7}
135
+ : {have: 7, need: 7},
136
+ };
137
+ next.nextSpawnAt = now + spawnMs;
138
+ } else {
139
+ /* Board full: try again shortly rather than skipping a beat. */
140
+ next.nextSpawnAt = now + 120;
141
+ }
142
+ }
143
+
144
+ return next;
145
+ }
146
+
147
+ /**
148
+ * Stamp the card in `slot`.
149
+ *
150
+ * Stamping an empty slot is not punished: the game is about judging
151
+ * what is in front of you, and a jumpy hand is not the mistake being
152
+ * taught here.
153
+ */
154
+ export function stamp(state, slot, now) {
155
+ if (state.over) return state;
156
+ const card = state.slots[slot];
157
+ if (!card) return state;
158
+
159
+ const slots = [...state.slots];
160
+ slots[slot] = null;
161
+
162
+ if (card.kind !== READY) {
163
+ return {...loseLife({...state, slots}), slots};
164
+ }
165
+
166
+ const combo = state.combo + 1;
167
+ const gained = state.cfg.pointsPerAdopt + (combo - 1) * state.cfg.comboBonus;
168
+ return {
169
+ ...state,
170
+ slots,
171
+ score: state.score + gained,
172
+ combo,
173
+ bestCombo: Math.max(state.bestCombo, combo),
174
+ adopted: state.adopted + 1,
175
+ lastGain: {slot, points: gained, at: now},
176
+ };
177
+ }
178
+
179
+ /** The line that goes on the game-over card and into the post. */
180
+ export function summarise(state, locale = 'en') {
181
+ const n = (v) => Number(v || 0).toLocaleString(locale);
182
+ return locale === 'nl'
183
+ ? `${n(state.adopted)} besluiten vastgesteld · reeks ${n(state.bestCombo)}`
184
+ : `${n(state.adopted)} decisions adopted · streak ${n(state.bestCombo)}`;
185
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * <ThemeSeamMock />
3
+ *
4
+ * One application, twice: the stock Conduction finish underneath, a
5
+ * second design system stitched over the top of it. A seam travels
6
+ * across the frame and the surface behind it changes finish, then the
7
+ * loop hands the frame back.
8
+ *
9
+ * The mock makes one argument, and it is the argument Thematiq makes:
10
+ * an application that paints from tokens rethemes when the tokens
11
+ * change. Nothing here restyles a component. The themed layer is the
12
+ * same <AppMock /> inside a wrapper that redefines the palette and
13
+ * radius tokens, which is exactly what installing a token set does to
14
+ * a Nextcloud.
15
+ *
16
+ * Usage:
17
+ *
18
+ * <ThemeSeamMock app="openregister" theme="lasuite" />
19
+ * <ThemeSeamMock app="nldesign" size="sm" running={false} />
20
+ *
21
+ * Props:
22
+ * - app: an AppMock slug (default 'openregister')
23
+ * - theme: a key of THEMES (default 'lasuite') — the finish that
24
+ * is stitched over the stock one
25
+ * - size: 'sm' | 'md' (default) — forwarded to AppMock
26
+ * - running: boolean (default true). false renders the themed end
27
+ * state with the seam at rest, the same thing
28
+ * prefers-reduced-motion gives.
29
+ * - label: optional caption under the frame
30
+ * - className: string
31
+ *
32
+ * Both layers run their own AppMock with `running={false}`: the
33
+ * variant's own loop would compete with the seam for attention, and
34
+ * the two layers have to stay in identical states or the wipe reveals
35
+ * a different screen instead of a different finish.
36
+ */
37
+
38
+ import React from 'react';
39
+ import AppMock from '../AppMock/AppMock.jsx';
40
+ import styles from './ThemeSeamMock.module.css';
41
+
42
+ /* Each theme is a class on the themed layer that redefines tokens.
43
+ Adding one is a CSS block plus a line here, never a new component. */
44
+ const THEMES = {
45
+ lasuite: {className: 'lasuite', label: 'La Suite'},
46
+ };
47
+
48
+ export default function ThemeSeamMock({
49
+ app = 'openregister',
50
+ theme = 'lasuite',
51
+ size = 'md',
52
+ running = true,
53
+ label,
54
+ className,
55
+ }) {
56
+ const resolvedTheme = THEMES[theme] || THEMES.lasuite;
57
+ const composed = [
58
+ styles.seamScene,
59
+ styles[`size-${size}`],
60
+ !running && styles.static,
61
+ className,
62
+ ].filter(Boolean).join(' ');
63
+
64
+ return (
65
+ <figure className={composed}>
66
+ <div className={styles.stack}>
67
+ <div className={styles.layer}>
68
+ <AppMock app={app} size={size} running={false} />
69
+ </div>
70
+ <div className={[styles.layer, styles.themed, styles[resolvedTheme.className]].join(' ')}>
71
+ <AppMock app={app} size={size} running={false} />
72
+ </div>
73
+ {/* The seam itself: the stitch line the two halves meet on. */}
74
+ <span className={styles.seam} aria-hidden="true" />
75
+ </div>
76
+ {label && <figcaption className={styles.caption}>{label}</figcaption>}
77
+ </figure>
78
+ );
79
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * <ThemeSeamMock /> styles.
3
+ *
4
+ * Three things happen here.
5
+ *
6
+ * 1. The themed layer sits exactly on top of the stock one and is
7
+ * clipped from the left, so animating the clip wipes one finish
8
+ * over the other. Both layers render the same frame, so the wipe
9
+ * reads as a change of finish rather than a change of screen.
10
+ * 2. The themed finish is nothing but redefined tokens. Every rule in
11
+ * AppMock.module.css paints from the palette and radius tokens, so
12
+ * redefining them on an ancestor rethemes the whole frame without
13
+ * one component-level override. That is the mock's entire claim.
14
+ * 3. The seam travels with the clip edge, drawn as a stitch rather
15
+ * than a plain line.
16
+ *
17
+ * Conventions from the animated-atomics waves apply: one duration
18
+ * token (--ts-dur) drives the loop, the loop resets while the seam is
19
+ * invisible, and `.static` / prefers-reduced-motion render the
20
+ * themed end state rather than a blank or a half-wiped frame.
21
+ */
22
+
23
+ .seamScene {
24
+ --ts-dur: 9s;
25
+ margin: 0;
26
+ display: inline-flex;
27
+ flex-direction: column;
28
+ gap: var(--space-2);
29
+ width: 100%;
30
+ max-width: 720px;
31
+ }
32
+ /* Scoped to .seamScene on purpose. `size-sm` / `size-md` are class
33
+ names several mock components share, so the extracted stylesheet
34
+ merges them into one selector group that can land before
35
+ .seamScene's own rule. At equal specificity that group loses, the
36
+ scene keeps the 720px default while the frame inside it caps at
37
+ 480, and the seam then travels 24px past the frame it is crossing.
38
+ Chaining the two classes settles it by specificity instead of by
39
+ source order. Keep these in step with AppMock's frame widths: the
40
+ scene box and the frame box have to be the same box. */
41
+ .seamScene.size-sm { max-width: 480px; }
42
+ .seamScene.size-md { max-width: 720px; }
43
+
44
+ /* Both layers occupy one grid cell, and the cell carries the frame's
45
+ own 16/10 ratio. Stacking them with `position: absolute` instead
46
+ lets the cell take its height from the surrounding column, and then
47
+ the frame's aspect ratio makes the frame narrower than the box the
48
+ seam and the clip run across: the seam finishes past the right edge
49
+ of the frame it is supposed to be crossing. The grid cell and the
50
+ frame are the same box, so they cannot drift apart. */
51
+ .stack {
52
+ position: relative;
53
+ display: grid;
54
+ width: 100%;
55
+ aspect-ratio: 16 / 10;
56
+ isolation: isolate;
57
+ }
58
+
59
+ .layer {
60
+ grid-area: 1 / 1;
61
+ width: 100%;
62
+ height: 100%;
63
+ }
64
+
65
+ /* ======================================================================
66
+ The finishes
67
+ ====================================================================== */
68
+
69
+ /* La Suite: neutral greyscale surfaces, violet accent, four-pixel
70
+ corners. The values come from the deployed Cunningham palette; the
71
+ point is not the exact hex but that nothing below the token layer
72
+ had to change. */
73
+ .lasuite {
74
+ --c-blue-cobalt: #6A6AF4;
75
+ --c-cobalt-900: #161616;
76
+ --c-cobalt-800: #242424;
77
+ --c-cobalt-700: #3A3A3A;
78
+ --c-cobalt-600: #6A6A6A;
79
+ --c-cobalt-500: #6A6AF4;
80
+ --c-cobalt-400: #929292;
81
+ --c-cobalt-300: #CECECE;
82
+ --c-cobalt-200: #E5E5E5;
83
+ --c-cobalt-100: #EEEEEE;
84
+ --c-cobalt-50: #F6F6F6;
85
+ --c-lavender-500: #6A6AF4;
86
+ --c-lavender-300: #C5C5FB;
87
+ --radius-lg: 4px;
88
+ --radius-md: 4px;
89
+ --radius-sm: 2px;
90
+ }
91
+
92
+ /* ======================================================================
93
+ The seam
94
+ ====================================================================== */
95
+
96
+ .seam {
97
+ position: absolute;
98
+ top: -2%;
99
+ bottom: -2%;
100
+ left: 0;
101
+ width: 2px;
102
+ z-index: 2;
103
+ /* A stitch, not a rule: dashes with a cross-hatch either side. */
104
+ background:
105
+ repeating-linear-gradient(
106
+ to bottom,
107
+ var(--c-cobalt-700) 0 6px,
108
+ transparent 6px 12px
109
+ );
110
+ opacity: 0;
111
+ }
112
+ .seam::before,
113
+ .seam::after {
114
+ content: '';
115
+ position: absolute;
116
+ top: 0;
117
+ bottom: 0;
118
+ width: 7px;
119
+ background:
120
+ repeating-linear-gradient(
121
+ to bottom,
122
+ transparent 0 3px,
123
+ var(--c-cobalt-400) 3px 4px,
124
+ transparent 4px 12px
125
+ );
126
+ }
127
+ .seam::before { right: 100%; }
128
+ .seam::after { left: 100%; }
129
+
130
+ .caption {
131
+ font-family: var(--conduction-typography-font-family-code);
132
+ font-size: 11px;
133
+ letter-spacing: 0.06em;
134
+ color: var(--c-cobalt-400);
135
+ text-transform: uppercase;
136
+ text-align: center;
137
+ }
138
+
139
+ /* ======================================================================
140
+ The loop
141
+ ====================================================================== */
142
+
143
+ /* End state, and what a frozen frame shows: the themed finish, whole,
144
+ with no seam across it. */
145
+ .themed { clip-path: inset(0 0 0 0); }
146
+
147
+ @media (prefers-reduced-motion: no-preference) {
148
+ .seamScene:not(.static) .themed {
149
+ animation: tsWipe var(--ts-dur) cubic-bezier(0.4, 0, 0.2, 1) infinite;
150
+ }
151
+ .seamScene:not(.static) .seam {
152
+ animation: tsSeam var(--ts-dur) cubic-bezier(0.4, 0, 0.2, 1) infinite;
153
+ }
154
+
155
+ /* Stock frame for the first beat, the finish wipes across, then it
156
+ holds. The reset happens in one step at the very end, while the
157
+ themed layer is fading rather than sliding, so no rewind is
158
+ visible. */
159
+ @keyframes tsWipe {
160
+ 0%, 14% { clip-path: inset(0 100% 0 0); opacity: 1; }
161
+ 52% { clip-path: inset(0 0 0 0); opacity: 1; }
162
+ 88% { clip-path: inset(0 0 0 0); opacity: 1; }
163
+ 97% { clip-path: inset(0 0 0 0); opacity: 0; }
164
+ 97.01% { clip-path: inset(0 100% 0 0); opacity: 0; }
165
+ 100% { clip-path: inset(0 100% 0 0); opacity: 1; }
166
+ }
167
+
168
+ /* The seam has to share tsWipe's start (14%), end (52%) and range
169
+ (0 to 100%) exactly. Starting it two percent later and four
170
+ percent along left the stitch trailing the edge it is supposed to
171
+ be cutting by about thirty pixels in the middle of the sweep. */
172
+ @keyframes tsSeam {
173
+ 0%, 13.9% { left: 0; opacity: 0; }
174
+ 14% { left: 0; opacity: 1; }
175
+ 52% { left: 100%; opacity: 1; }
176
+ 60%, 100% { left: 100%; opacity: 0; }
177
+ }
178
+ }