@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.
- package/MISSING_COMPONENTS.md +1 -0
- package/package.json +1 -1
- package/src/__tests__/no-icu-messages.test.js +57 -0
- package/src/components/BlueprintRush/BlueprintRush.jsx +238 -0
- package/src/components/BlueprintRush/BlueprintRush.module.css +186 -0
- package/src/components/BlueprintRush/__tests__/engine.test.js +154 -0
- package/src/components/BlueprintRush/engine.js +172 -0
- package/src/components/DeadlineDefender/DeadlineDefender.jsx +237 -0
- package/src/components/DeadlineDefender/DeadlineDefender.module.css +193 -0
- package/src/components/DeadlineDefender/__tests__/engine.test.js +163 -0
- package/src/components/DeadlineDefender/engine.js +188 -0
- package/src/components/DetailHero/DetailHero.jsx +11 -4
- package/src/components/DetailHero/__tests__/DetailHero.downloads.test.js +160 -0
- package/src/components/FeaturedCard/FeaturedCard.jsx +14 -1
- package/src/components/FeaturedCard/FeaturedCard.module.css +5 -0
- package/src/components/FeaturedCard/__tests__/FeaturedCard.visual.test.js +110 -0
- package/src/components/GameModal/GameModal.jsx +255 -50
- package/src/components/GameModal/GameModal.module.css +103 -0
- package/src/components/GameModal/__tests__/scores.test.js +123 -0
- package/src/components/GameModal/__tests__/share.test.js +83 -0
- package/src/components/GameModal/scores.js +149 -0
- package/src/components/GameModal/share.js +97 -0
- package/src/components/RecordRun/RecordRun.jsx +245 -0
- package/src/components/RecordRun/RecordRun.module.css +208 -0
- package/src/components/RecordRun/__tests__/engine.test.js +191 -0
- package/src/components/RecordRun/engine.js +180 -0
- package/src/components/StampRush/StampRush.jsx +232 -0
- package/src/components/StampRush/StampRush.module.css +188 -0
- package/src/components/StampRush/__tests__/engine.test.js +182 -0
- package/src/components/StampRush/engine.js +185 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.jsx +79 -0
- package/src/components/ThemeSeamMock/ThemeSeamMock.module.css +178 -0
- package/src/components/ThemeSeamMock/__tests__/ThemeSeamMock.render.test.js +122 -0
- package/src/components/index.js +5 -0
- package/src/data/app-downloads.js +21 -0
- package/src/index.js +10 -0
- 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
|
+
}
|