@conduction/docusaurus-preset 3.37.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 (42) 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/AiDisclosure/AiDisclosure.jsx +45 -21
  5. package/src/components/AiDisclosure/AiDisclosure.module.css +38 -3
  6. package/src/components/AiDisclosure/__tests__/AiDisclosure.render.test.js +25 -13
  7. package/src/components/AiDisclosure/__tests__/disclosure.test.js +60 -0
  8. package/src/components/AiDisclosure/disclosure.js +60 -4
  9. package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
  10. package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
  11. package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
  12. package/src/components/BlueprintRush/engine.js +172 -0
  13. package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
  14. package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
  15. package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
  16. package/src/components/DeadlineDefender/engine.js +188 -0
  17. package/src/components/DetailHero/DetailHero.jsx +11 -4
  18. package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
  19. package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
  20. package/src/components/FeaturedCard/FeaturedCard.module.css +10 -0
  21. package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
  22. package/src/components/GameModal/GameModal.jsx +255 -50
  23. package/src/components/GameModal/GameModal.module.css +103 -0
  24. package/src/components/GameModal/__tests__/scores.test.js +123 -0
  25. package/src/components/GameModal/__tests__/share.test.js +83 -0
  26. package/src/components/GameModal/scores.js +149 -0
  27. package/src/components/GameModal/share.js +97 -0
  28. package/src/components/RecordRun/RecordRun.jsx +245 -0
  29. package/src/components/RecordRun/RecordRun.module.css +208 -0
  30. package/src/components/RecordRun/__tests__/engine.test.js +191 -0
  31. package/src/components/RecordRun/engine.js +180 -0
  32. package/src/components/StampRush/StampRush.jsx +232 -0
  33. package/src/components/StampRush/StampRush.module.css +188 -0
  34. package/src/components/StampRush/__tests__/engine.test.js +182 -0
  35. package/src/components/StampRush/engine.js +185 -0
  36. package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
  37. package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
  38. package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
  39. package/src/components/index.js +5 -0
  40. package/src/data/app-downloads.js +21 -0
  41. package/src/index.js +10 -0
  42. package/src/theme/Footer/index.jsx +10 -1
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Blueprint rush — the rules, with no DOM and no clock of its own.
3
+ *
4
+ * Buildiq's game. A blueprint asks for the parts an app needs: where
5
+ * the records live, what one looks like, how people fill it in, who
6
+ * may see it. The tray offers the parts plus a few that belong to
7
+ * some other app. Fit the blueprint before the clock runs out; every
8
+ * finished app buys you more time.
9
+ *
10
+ * Unlike the other two games this one is a puzzle rather than a
11
+ * reaction test: the pressure comes from reading four slots at once,
12
+ * not from a card you have to hit in time. Picking a part that does
13
+ * not belong costs seconds rather than a life, because in the builder
14
+ * a wrong part is an undo, not a disaster.
15
+ *
16
+ * Time and randomness are injected. The component owns the clock.
17
+ */
18
+
19
+ /* Every part an app can need. `slot` is what the blueprint asks for,
20
+ in the reader's words; the component supplies the sentences. */
21
+ export const PARTS = [
22
+ 'register',
23
+ 'schema',
24
+ 'form',
25
+ 'view',
26
+ 'flow',
27
+ 'permission',
28
+ 'widget',
29
+ 'notification',
30
+ ];
31
+
32
+ export const DEFAULTS = {
33
+ /* Long enough to read four slots and a tray on the first blueprint,
34
+ and the bonus keeps a good player alive rather than the start
35
+ being generous. */
36
+ startMs: 30000,
37
+ bonusMs: 7000,
38
+ penaltyMs: 2500,
39
+ slots: 4,
40
+ distractors: 2,
41
+ pointsPerApp: 25,
42
+ comboBonus: 5,
43
+ };
44
+
45
+ function mulberry32(seed) {
46
+ let a = seed >>> 0;
47
+ return function random() {
48
+ a |= 0; a = (a + 0x6D2B79F5) | 0;
49
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
50
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
51
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
52
+ };
53
+ }
54
+
55
+ function shuffled(list, random) {
56
+ const out = [...list];
57
+ for (let i = out.length - 1; i > 0; i--) {
58
+ const j = Math.floor(random() * (i + 1));
59
+ [out[i], out[j]] = [out[j], out[i]];
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function dealBlueprint(state, now) {
65
+ const pool = shuffled(PARTS, state.random);
66
+ const needed = pool.slice(0, state.cfg.slots);
67
+ const distractors = pool.slice(state.cfg.slots, state.cfg.slots + state.cfg.distractors);
68
+ return {
69
+ ...state,
70
+ blueprint: {
71
+ /* Slots keep the order they were dealt in; the tray is shuffled
72
+ separately, so the answer is never "top to bottom". */
73
+ needed,
74
+ filled: [],
75
+ tray: shuffled([...needed, ...distractors], state.random),
76
+ startedAt: now,
77
+ },
78
+ };
79
+ }
80
+
81
+ export function createGame({seed = Date.now(), now = 0, config = {}} = {}) {
82
+ const cfg = {...DEFAULTS, ...config};
83
+ const base = {
84
+ cfg,
85
+ random: mulberry32(seed),
86
+ blueprint: null,
87
+ score: 0,
88
+ built: 0,
89
+ wrong: 0,
90
+ combo: 0,
91
+ bestCombo: 0,
92
+ endsAt: now + cfg.startMs,
93
+ last: null,
94
+ over: false,
95
+ };
96
+ return dealBlueprint(base, now);
97
+ }
98
+
99
+ /** Milliseconds left on the clock, never negative. */
100
+ export function timeLeft(state, now) {
101
+ return Math.max(0, state.endsAt - now);
102
+ }
103
+
104
+ /** Advance to `now`. The only thing the clock can do here is end it. */
105
+ export function step(state, now) {
106
+ if (state.over) return state;
107
+ if (timeLeft(state, now) > 0) return state;
108
+ return {...state, over: true, last: {result: 'timeout', at: now}};
109
+ }
110
+
111
+ /**
112
+ * Put `part` on the blueprint.
113
+ *
114
+ * A part the blueprint does not ask for, or one already placed, costs
115
+ * seconds. Completing the blueprint scores, adds time and deals the
116
+ * next one.
117
+ */
118
+ export function place(state, part, now) {
119
+ if (state.over || !state.blueprint) return state;
120
+
121
+ const {needed, filled} = state.blueprint;
122
+ const wanted = needed.includes(part);
123
+ const already = filled.includes(part);
124
+
125
+ if (!wanted || already) {
126
+ const endsAt = state.endsAt - state.cfg.penaltyMs;
127
+ const out = {
128
+ ...state,
129
+ endsAt,
130
+ wrong: state.wrong + 1,
131
+ combo: 0,
132
+ last: {result: already ? 'duplicate' : 'wrong', part, at: now},
133
+ };
134
+ /* The penalty can end the run: check here rather than waiting for
135
+ the next tick, so the game does not keep taking clicks after the
136
+ clock has already gone. */
137
+ return timeLeft(out, now) > 0 ? out : {...out, over: true};
138
+ }
139
+
140
+ const nextFilled = [...filled, part];
141
+ const done = nextFilled.length === needed.length;
142
+
143
+ if (!done) {
144
+ return {
145
+ ...state,
146
+ blueprint: {...state.blueprint, filled: nextFilled},
147
+ last: {result: 'placed', part, at: now},
148
+ };
149
+ }
150
+
151
+ const combo = state.combo + 1;
152
+ const gained = state.cfg.pointsPerApp + (combo - 1) * state.cfg.comboBonus;
153
+ const built = {
154
+ ...state,
155
+ blueprint: {...state.blueprint, filled: nextFilled},
156
+ score: state.score + gained,
157
+ built: state.built + 1,
158
+ combo,
159
+ bestCombo: Math.max(state.bestCombo, combo),
160
+ endsAt: state.endsAt + state.cfg.bonusMs,
161
+ last: {result: 'built', points: gained, at: now},
162
+ };
163
+ return dealBlueprint(built, now);
164
+ }
165
+
166
+ /** The line that goes on the game-over card and into the post. */
167
+ export function summarise(state, locale = 'en') {
168
+ const n = (v) => Number(v || 0).toLocaleString(locale);
169
+ return locale === 'nl'
170
+ ? `${n(state.built)} apps gebouwd · reeks ${n(state.bestCombo)}`
171
+ : `${n(state.built)} apps built · streak ${n(state.bestCombo)}`;
172
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * <DeadlineDefender />
3
+ *
4
+ * Dossiq's mini-game. A case arrives with its legal deadline already
5
+ * running. Read it, send it to the right next step, and do it before
6
+ * the clock empties. The wrong step costs the same as running out of
7
+ * time, because in a real queue both end the same way: the case sits
8
+ * somewhere nobody is looking.
9
+ *
10
+ * The rules live in ./engine.js with no DOM and no clock. This file
11
+ * owns the clock, the keyboard and the paint.
12
+ *
13
+ * Usage on a product page:
14
+ *
15
+ * <DeadlineDefender />
16
+ *
17
+ * Fires the shared `connext:gameend` event on game over, and listens
18
+ * for `connext:gamereplay`, like every other mini-game.
19
+ *
20
+ * Accessibility: the three steps are real buttons, reachable by tab
21
+ * and by the keys 1, 2 and 3. The case text carries the whole puzzle,
22
+ * so nothing depends on the countdown bar being seen; the bar is
23
+ * announced as a percentage for anyone who cannot see it drain.
24
+ */
25
+
26
+ import React, {useCallback, useEffect, useRef, useState} from 'react';
27
+ import {translate} from '@docusaurus/Translate';
28
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
29
+ import {
30
+ createGame, step, route, remaining, summarise, LANES, INTAKE, REVIEW, DECISION,
31
+ } from './engine';
32
+ import styles from './DeadlineDefender.module.css';
33
+
34
+ const GAME_ID = 'deadline-defender';
35
+ const TICK_MS = 80;
36
+
37
+ function laneLabel(lane) {
38
+ if (lane === INTAKE) {
39
+ return translate({id: 'preset.deadlineDefender.lane.intake', message: 'Intake', description: 'Name of the intake step in the deadline-defender game'});
40
+ }
41
+ if (lane === REVIEW) {
42
+ return translate({id: 'preset.deadlineDefender.lane.review', message: 'Assessment', description: 'Name of the assessment step in the deadline-defender game'});
43
+ }
44
+ return translate({id: 'preset.deadlineDefender.lane.decision', message: 'Decision', description: 'Name of the decision step in the deadline-defender game'});
45
+ }
46
+
47
+ /* One sentence per situation, each written so the right step follows
48
+ from what it says rather than from remembering a colour. */
49
+ function situationText(key) {
50
+ switch (key) {
51
+ case 'permitReceived':
52
+ return translate({id: 'preset.deadlineDefender.case.permitReceived', message: 'A permit application came in through the portal. Nothing registered yet.', description: 'Deadline-defender case that belongs in intake'});
53
+ case 'objectionReceived':
54
+ return translate({id: 'preset.deadlineDefender.case.objectionReceived', message: 'An objection arrived by post. It has not been logged.', description: 'Deadline-defender case that belongs in intake'});
55
+ case 'complaintReceived':
56
+ return translate({id: 'preset.deadlineDefender.case.complaintReceived', message: 'A complaint was filed this morning. No case number yet.', description: 'Deadline-defender case that belongs in intake'});
57
+ case 'documentsComplete':
58
+ return translate({id: 'preset.deadlineDefender.case.documentsComplete', message: 'The last missing document came in. The file is complete.', description: 'Deadline-defender case that belongs in assessment'});
59
+ case 'siteVisitDone':
60
+ return translate({id: 'preset.deadlineDefender.case.siteVisitDone', message: 'The site visit is done and the report is attached.', description: 'Deadline-defender case that belongs in assessment'});
61
+ case 'adviceReturned':
62
+ return translate({id: 'preset.deadlineDefender.case.adviceReturned', message: 'The advice from the fire service came back. Nobody has read it against the file.', description: 'Deadline-defender case that belongs in assessment'});
63
+ case 'assessmentDone':
64
+ return translate({id: 'preset.deadlineDefender.case.assessmentDone', message: 'The assessment is finished and the draft is written.', description: 'Deadline-defender case that belongs in decision'});
65
+ case 'objectionAssessed':
66
+ return translate({id: 'preset.deadlineDefender.case.objectionAssessed', message: 'The objection has been assessed. It needs signing off.', description: 'Deadline-defender case that belongs in decision'});
67
+ default:
68
+ return translate({id: 'preset.deadlineDefender.case.enforcementReady', message: 'Enforcement has been prepared and checked. It waits on a signature.', description: 'Deadline-defender case that belongs in decision'});
69
+ }
70
+ }
71
+
72
+ export default function DeadlineDefender({className}) {
73
+ const {i18n} = useDocusaurusContext();
74
+ const locale = (i18n && i18n.currentLocale) || 'en';
75
+
76
+ const [game, setGame] = useState(null);
77
+ const [left, setLeft] = useState(0);
78
+ const gameRef = useRef(null);
79
+ const startedAtRef = useRef(0);
80
+ const endedRef = useRef(false);
81
+
82
+ const running = Boolean(game) && !game.over;
83
+
84
+ const begin = useCallback(() => {
85
+ endedRef.current = false;
86
+ startedAtRef.current = (typeof performance !== 'undefined' ? performance.now() : Date.now());
87
+ const fresh = createGame({seed: Math.floor(Math.random() * 2 ** 31), now: 0});
88
+ gameRef.current = fresh;
89
+ setGame(fresh);
90
+ setLeft(1);
91
+ }, []);
92
+
93
+ useEffect(() => {
94
+ if (!running) return undefined;
95
+ const id = setInterval(() => {
96
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
97
+ const next = step(gameRef.current, now);
98
+ gameRef.current = next;
99
+ setGame(next);
100
+ setLeft(remaining(next, now));
101
+ }, TICK_MS);
102
+ return () => clearInterval(id);
103
+ }, [running]);
104
+
105
+ useEffect(() => {
106
+ if (!game || !game.over || endedRef.current) return;
107
+ endedRef.current = true;
108
+ if (typeof window === 'undefined') return;
109
+ window.dispatchEvent(new CustomEvent('connext:gameend', {
110
+ detail: {
111
+ id: GAME_ID,
112
+ won: false,
113
+ score: game.score,
114
+ summary: summarise(game, locale),
115
+ title: translate({id: 'preset.deadlineDefender.over.title', message: 'The queue won.', description: 'Headline on the game-over dialog after a deadline-defender run'}),
116
+ subtitle: translate({id: 'preset.deadlineDefender.over.subtitle', message: 'Three cases in the wrong place, or past their date. Both count.', description: 'Subtitle on the game-over dialog after a deadline-defender run'}),
117
+ },
118
+ }));
119
+ }, [game, locale]);
120
+
121
+ useEffect(() => {
122
+ if (typeof window === 'undefined') return undefined;
123
+ const onReplay = (e) => { if (e.detail && e.detail.id === GAME_ID) begin(); };
124
+ window.addEventListener('connext:gamereplay', onReplay);
125
+ return () => window.removeEventListener('connext:gamereplay', onReplay);
126
+ }, [begin]);
127
+
128
+ const send = useCallback((lane) => {
129
+ if (!gameRef.current || gameRef.current.over || !gameRef.current.current) return;
130
+ const now = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - startedAtRef.current;
131
+ const next = route(gameRef.current, lane, now);
132
+ gameRef.current = next;
133
+ setGame(next);
134
+ setLeft(remaining(next, now));
135
+ }, []);
136
+
137
+ useEffect(() => {
138
+ if (!running || typeof window === 'undefined') return undefined;
139
+ const onKey = (e) => {
140
+ const n = Number(e.key);
141
+ if (Number.isInteger(n) && n >= 1 && n <= LANES.length) {
142
+ e.preventDefault();
143
+ send(LANES[n - 1]);
144
+ }
145
+ };
146
+ window.addEventListener('keydown', onKey);
147
+ return () => window.removeEventListener('keydown', onKey);
148
+ }, [running, send]);
149
+
150
+ const current = game ? game.current : null;
151
+ const lives = game ? game.lives : 3;
152
+ const score = game ? game.score : 0;
153
+ const pct = Math.round(left * 100);
154
+ const last = game && game.last ? game.last.result : null;
155
+
156
+ return (
157
+ <section className={[styles.dd, className].filter(Boolean).join(' ')} aria-labelledby="deadline-defender-title">
158
+ <header className={styles.head}>
159
+ <div>
160
+ <p className={styles.eyebrow}>
161
+ {translate({id: 'preset.deadlineDefender.eyebrow', message: 'Mini-game', description: 'Eyebrow above the deadline-defender game on a product page'})}
162
+ </p>
163
+ <h3 className={styles.title} id="deadline-defender-title">
164
+ {translate({id: 'preset.deadlineDefender.title', message: 'Deadline defender', description: 'Name of the Dossiq mini-game'})}
165
+ </h3>
166
+ <p className={styles.lede}>
167
+ {translate({id: 'preset.deadlineDefender.lede', message: 'Every case arrives with its clock already running. Send it to the right step before the time is gone. The wrong step costs the same as being late.', description: 'One-line explanation of the deadline-defender rules'})}
168
+ </p>
169
+ </div>
170
+ <div className={styles.hud} role="status" aria-live="polite">
171
+ <span className={styles.hudPill}>
172
+ {translate({id: 'preset.deadlineDefender.hud.score', message: 'Score {score}', description: 'Score readout on the deadline-defender HUD'}, {score: Number(score).toLocaleString(locale)})}
173
+ </span>
174
+ <span className={styles.hudPill}>
175
+ {translate({id: 'preset.deadlineDefender.hud.lives', message: 'Cases you can still lose {lives}', description: 'Remaining-lives readout on the deadline-defender HUD'}, {lives})}
176
+ </span>
177
+ </div>
178
+ </header>
179
+
180
+ <div className={styles.desk}>
181
+ {current ? (
182
+ <article className={styles.file} aria-live="polite">
183
+ <p className={styles.fileNo}>
184
+ {translate({id: 'preset.deadlineDefender.caseNo', message: 'Case {id}', description: 'Case-number line on the deadline-defender file. {id} is the case number.'}, {id: current.id})}
185
+ </p>
186
+ <p className={styles.fileText}>{situationText(current.key)}</p>
187
+ <div
188
+ className={styles.clock}
189
+ role="progressbar"
190
+ aria-valuemin={0}
191
+ aria-valuemax={100}
192
+ aria-valuenow={pct}
193
+ aria-label={translate({id: 'preset.deadlineDefender.clock', message: 'Time left on this deadline', description: 'Accessible name of the deadline countdown bar'})}>
194
+ <div
195
+ className={[styles.clockFill, left < 0.3 && styles.clockLow].filter(Boolean).join(' ')}
196
+ style={{width: `${pct}%`}}
197
+ />
198
+ </div>
199
+ </article>
200
+ ) : (
201
+ <p className={styles.empty}>
202
+ {game
203
+ ? translate({id: 'preset.deadlineDefender.next', message: 'Next case…', description: 'Placeholder between two cases in the deadline-defender game'})
204
+ : translate({id: 'preset.deadlineDefender.idle', message: 'The queue is waiting for you.', description: 'Placeholder on the deadline-defender desk before the game starts'})}
205
+ </p>
206
+ )}
207
+ </div>
208
+
209
+ <div className={styles.lanes}>
210
+ {LANES.map((lane, i) => (
211
+ <button
212
+ key={lane}
213
+ type="button"
214
+ className={styles.lane}
215
+ onClick={() => send(lane)}
216
+ disabled={!running || !current}>
217
+ <span className={styles.laneKey} aria-hidden="true">{i + 1}</span>
218
+ <span className={styles.laneName}>{laneLabel(lane)}</span>
219
+ </button>
220
+ ))}
221
+ </div>
222
+
223
+ <footer className={styles.foot}>
224
+ <button type="button" className={styles.start} onClick={begin}>
225
+ {game
226
+ ? translate({id: 'preset.deadlineDefender.restart', message: 'Restart', description: 'Button that restarts the deadline-defender game'})
227
+ : translate({id: 'preset.deadlineDefender.start', message: 'Open the queue', description: 'Button that starts the deadline-defender game'})}
228
+ </button>
229
+ <p className={styles.hint} role="status" aria-live="polite">
230
+ {last === 'misrouted' && translate({id: 'preset.deadlineDefender.feedback.misrouted', message: 'Wrong step. That case is now somewhere nobody is looking.', description: 'Feedback after routing a case to the wrong step'})}
231
+ {last === 'missed' && translate({id: 'preset.deadlineDefender.feedback.missed', message: 'Out of time. The deadline ran while it sat there.', description: 'Feedback after letting a deadline expire'})}
232
+ {last !== 'misrouted' && last !== 'missed' && translate({id: 'preset.deadlineDefender.hint', message: 'Click a step, or press 1, 2 or 3. Each case in a row is worth more than the last.', description: 'Hint under the deadline-defender board explaining the controls and the streak bonus'})}
233
+ </p>
234
+ </footer>
235
+ </section>
236
+ );
237
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * <DeadlineDefender /> styles.
3
+ *
4
+ * Tokens only. One accent: the countdown once it is nearly gone. The
5
+ * case text carries the puzzle, so the colour never has to.
6
+ */
7
+
8
+ .dd {
9
+ border: 1px solid var(--c-cobalt-100);
10
+ border-radius: var(--radius-lg);
11
+ background: white;
12
+ padding: clamp(16px, 3vw, 28px);
13
+ font-family: var(--conduction-typography-font-family-body);
14
+ }
15
+
16
+ .head {
17
+ display: flex;
18
+ flex-wrap: wrap;
19
+ gap: 16px;
20
+ align-items: flex-start;
21
+ justify-content: space-between;
22
+ margin-bottom: 18px;
23
+ }
24
+
25
+ .eyebrow {
26
+ margin: 0 0 4px;
27
+ font-family: var(--conduction-typography-font-family-code);
28
+ font-size: 11px;
29
+ letter-spacing: 0.12em;
30
+ text-transform: uppercase;
31
+ color: var(--c-orange-knvb);
32
+ }
33
+
34
+ .title {
35
+ margin: 0 0 6px;
36
+ font-size: 20px;
37
+ font-weight: 700;
38
+ color: var(--c-cobalt-900);
39
+ }
40
+
41
+ .lede {
42
+ margin: 0;
43
+ max-width: 56ch;
44
+ font-size: 14px;
45
+ line-height: 1.5;
46
+ color: var(--c-cobalt-700);
47
+ }
48
+
49
+ .hud {
50
+ display: flex;
51
+ gap: 8px;
52
+ flex-wrap: wrap;
53
+ font-family: var(--conduction-typography-font-family-code);
54
+ font-size: 12px;
55
+ font-variant-numeric: tabular-nums;
56
+ }
57
+ .hudPill {
58
+ padding: 6px 10px;
59
+ border-radius: var(--radius-pill);
60
+ background: var(--c-cobalt-50);
61
+ color: var(--c-cobalt-900);
62
+ white-space: nowrap;
63
+ }
64
+
65
+ /* The desk holds one case at a time, and keeps its height between two
66
+ of them so the lanes underneath never jump. */
67
+ .desk {
68
+ min-height: 132px;
69
+ display: flex;
70
+ align-items: center;
71
+ max-width: 620px;
72
+ }
73
+
74
+ .file {
75
+ width: 100%;
76
+ border: 1px solid var(--c-cobalt-200);
77
+ border-left: 3px solid var(--c-blue-cobalt);
78
+ border-radius: var(--radius-md);
79
+ background: var(--c-cobalt-50);
80
+ padding: 14px 16px;
81
+ }
82
+
83
+ .fileNo {
84
+ margin: 0 0 6px;
85
+ font-family: var(--conduction-typography-font-family-code);
86
+ font-size: 11px;
87
+ letter-spacing: 0.06em;
88
+ color: var(--c-cobalt-400);
89
+ }
90
+
91
+ .fileText {
92
+ margin: 0 0 12px;
93
+ font-size: 15px;
94
+ line-height: 1.45;
95
+ color: var(--c-cobalt-900);
96
+ }
97
+
98
+ .clock {
99
+ height: 6px;
100
+ border-radius: var(--radius-pill);
101
+ background: var(--c-cobalt-200);
102
+ overflow: hidden;
103
+ }
104
+ .clockFill {
105
+ height: 100%;
106
+ background: var(--c-mint-500);
107
+ /* The width is set per tick, so the transition only smooths the
108
+ steps between ticks; it must never outlast one. */
109
+ transition: width 80ms linear;
110
+ }
111
+ .clockLow { background: var(--c-orange-knvb); }
112
+
113
+ .empty {
114
+ margin: 0;
115
+ font-size: 14px;
116
+ color: var(--c-cobalt-400);
117
+ }
118
+
119
+ .lanes {
120
+ display: grid;
121
+ grid-template-columns: repeat(3, minmax(0, 1fr));
122
+ gap: 10px;
123
+ margin-top: 14px;
124
+ max-width: 620px;
125
+ }
126
+
127
+ .lane {
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ gap: 8px;
132
+ padding: 14px 10px;
133
+ border: 1px solid var(--c-cobalt-200);
134
+ border-radius: var(--radius-md);
135
+ background: white;
136
+ font-family: inherit;
137
+ font-size: 14px;
138
+ font-weight: 500;
139
+ color: var(--c-cobalt-900);
140
+ cursor: pointer;
141
+ transition: border-color 120ms ease, background 120ms ease;
142
+ }
143
+ .lane:hover:not(:disabled) { border-color: var(--c-blue-cobalt); background: var(--c-cobalt-50); }
144
+ .lane:disabled { opacity: 0.55; cursor: default; }
145
+ .lane:focus-visible { outline: 2px solid var(--c-blue-cobalt); outline-offset: 2px; }
146
+
147
+ .laneKey {
148
+ font-family: var(--conduction-typography-font-family-code);
149
+ font-size: 11px;
150
+ color: var(--c-cobalt-400);
151
+ border: 1px solid var(--c-cobalt-200);
152
+ border-radius: 3px;
153
+ padding: 1px 5px;
154
+ }
155
+
156
+ .foot {
157
+ display: flex;
158
+ flex-wrap: wrap;
159
+ align-items: center;
160
+ gap: 12px;
161
+ margin-top: 16px;
162
+ }
163
+
164
+ .start {
165
+ background: var(--c-blue-cobalt);
166
+ color: white;
167
+ border: 1px solid var(--c-blue-cobalt);
168
+ padding: 10px 18px;
169
+ border-radius: var(--radius-md);
170
+ font-family: inherit;
171
+ font-weight: 500;
172
+ font-size: 14px;
173
+ cursor: pointer;
174
+ transition: background 120ms ease;
175
+ }
176
+ .start:hover { background: var(--c-cobalt-700); border-color: var(--c-cobalt-700); }
177
+ .start:focus-visible { outline: 2px solid var(--c-cobalt-900); outline-offset: 2px; }
178
+
179
+ .hint {
180
+ margin: 0;
181
+ font-size: 12px;
182
+ color: var(--c-cobalt-400);
183
+ max-width: 52ch;
184
+ min-height: 1.5em;
185
+ }
186
+
187
+ @media (prefers-reduced-motion: no-preference) {
188
+ .file { animation: ddArrive 140ms ease-out both; }
189
+ @keyframes ddArrive {
190
+ from { opacity: 0; transform: translateY(5px); }
191
+ to { opacity: 1; transform: none; }
192
+ }
193
+ }