clauderipple 0.8.10 → 0.9.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/CHANGELOG.md +36 -0
- package/README.es.md +0 -4
- package/README.ja.md +0 -4
- package/README.ko.md +5 -1
- package/README.md +5 -1
- package/README.pt-BR.md +0 -4
- package/README.zh-CN.md +0 -4
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +226 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +370 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +119 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/chatgpt/index.js +6 -1
- package/dist/router/src/providers/chatgpt/translate.js +1 -1
- package/dist/router/src/providers/google/index.js +6 -2
- package/dist/router/src/providers/openai/index.js +4 -1
- package/dist/router/src/providers/openai/translate.js +1 -1
- package/dist/router/src/proxy.js +14 -3
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +128 -0
- package/package.json +7 -3
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
// The pictures of the desktop drawings, in one look: "the deep" (2026-10-12). Every band, card and
|
|
2
|
+
// panel is a capsule of dark water, warm graphite whatever the app's theme: 리플이 glows at its left,
|
|
3
|
+
// ripples spread from it, a few motes drift, and what the capsule is about tints that light (amber
|
|
4
|
+
// as the cache runs out, ice once it has, embers for a hot limit). Controls are drawn on it in light
|
|
5
|
+
// ink; colour is kept for points (a lit switch, a tile's picture, an alert's light). Pure: SVG only.
|
|
6
|
+
//
|
|
7
|
+
// Why one dark look on both themes (host facts, see parts.tsx and kit.tsx for the rest):
|
|
8
|
+
// - The host styles its own text and buttons per theme, and a mod cannot read which theme is on. A
|
|
9
|
+
// capsule that is always dark lets every colour be ours: host Text is given explicit light colours
|
|
10
|
+
// (never `dimColor`, the host's grey), and nothing on it is a labelled host Button.
|
|
11
|
+
// - A plain Svg is ours to the pixel, keeps its CSS motion and does not blink on a redraw. A redraw
|
|
12
|
+
// starts its motion over, so every loop opens on a pose that reads as going on (negative delays).
|
|
13
|
+
// - The desktop sets text at 13 px with 19 px lines; a cell is 1ch, 7.917 px. Controls are sized in
|
|
14
|
+
// whole cells so a picture and the presses laid over it share one grid.
|
|
15
|
+
|
|
16
|
+
import { chOf } from './theme'
|
|
17
|
+
|
|
18
|
+
/** One desktop cell (1ch of the host's 13 px sans), px. */
|
|
19
|
+
export const CELL_PX = 7.917
|
|
20
|
+
/** One desktop row (the host's line height), px. */
|
|
21
|
+
export const ROW_PX = 19
|
|
22
|
+
|
|
23
|
+
// ---- Colours ------------------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Host Text on a capsule: bright for values, soft for names, quiet for asides. */
|
|
26
|
+
export const INK = '#f2f0eb'
|
|
27
|
+
export const SUB = '#cbc8bf'
|
|
28
|
+
export const MUTE = '#9c988e'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The water: warm graphite, the Claude app's own dark family, so the capsule sits in the app on
|
|
32
|
+
* either theme and violet stays a point of light (리플이's glow, a switch that is on), never the
|
|
33
|
+
* ground (the owner, 2026-10-13: a violet ground "stands out far too much").
|
|
34
|
+
*/
|
|
35
|
+
const WATER = ['#262523', '#1f1e1c', '#1a1917'] as const
|
|
36
|
+
/** Lines drawn on the water (white at a strength), unlit marks, and drawn labels. */
|
|
37
|
+
const HAIR = '#ffffff'
|
|
38
|
+
const SOFT = '#d6d3cb'
|
|
39
|
+
const LABEL = '#e9e7e1'
|
|
40
|
+
|
|
41
|
+
/** The lights of the deep, by what they are about. */
|
|
42
|
+
export type Tone = 'violet' | 'cyan' | 'teal' | 'amber' | 'ember' | 'ice' | 'green' | 'red'
|
|
43
|
+
|
|
44
|
+
type Light = { hi: string; lo: string; glow: string; text: string; on: string }
|
|
45
|
+
export const TONE: Record<Tone, Light> = {
|
|
46
|
+
violet: { hi: '#c4b5fd', lo: '#7c5cff', glow: '#8b6bff', text: '#cdbfff', on: '#ffffff' },
|
|
47
|
+
cyan: { hi: '#a5f3fc', lo: '#22c7e6', glow: '#22d3ee', text: '#8fe9f8', on: '#04252e' },
|
|
48
|
+
teal: { hi: '#99f6e4', lo: '#14b8a6', glow: '#2dd4bf', text: '#86efdf', on: '#03302a' },
|
|
49
|
+
amber: { hi: '#fde68a', lo: '#f5a00b', glow: '#fbbf24', text: '#fcd77a', on: '#3a2604' },
|
|
50
|
+
ember: { hi: '#fdba74', lo: '#f9731f', glow: '#fb923c', text: '#fdbb84', on: '#3b1505' },
|
|
51
|
+
ice: { hi: '#bae6fd', lo: '#38bdf8', glow: '#7dd3fc', text: '#a7defb', on: '#062438' },
|
|
52
|
+
green: { hi: '#bbf7d0', lo: '#22c55e', glow: '#4ade80', text: '#93f0b4', on: '#052e16' },
|
|
53
|
+
red: { hi: '#fecdd3', lo: '#f43f5e', glow: '#fb7185', text: '#fda9b4', on: '#ffffff' },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Effort runs shallow to deep: cyan for low, through periwinkle, to violet for max. */
|
|
57
|
+
export const DEPTH = ['#67e8f9', '#7dd3fc', '#a5b4fc', '#a78bfa', '#c084fc'] as const
|
|
58
|
+
|
|
59
|
+
const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Apple SD Gothic Neo','Segoe UI',system-ui,sans-serif"
|
|
60
|
+
|
|
61
|
+
const r = (x: number): string => String(Math.round(x * 100) / 100)
|
|
62
|
+
|
|
63
|
+
/** One SVG document, `width` × `height` px; nothing in it moves when less motion is asked for. */
|
|
64
|
+
export function svg(width: number, height: number, body: string, css = '', extra = ''): string {
|
|
65
|
+
const base = 'svg{display:block}text{font-family:' + FONT + ';font-variant-numeric:tabular-nums}' + '@media (prefers-reduced-motion:reduce){*{animation:none!important}}'
|
|
66
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + r(width) + '" height="' + r(height) + '" viewBox="0 0 ' + r(width) + ' ' + r(height) + '"' + extra + '><style>' + base + css + '</style>' + body + '</svg>'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function esc(text: string): string {
|
|
70
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Pixels a run of host text takes, from its estimate in ch. */
|
|
74
|
+
export function textPx(text: string): number {
|
|
75
|
+
return chOf(text) * CELL_PX
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pixels a label drawn in an SVG takes at `size` px (the system sans: SF Pro, Apple SD Gothic Neo),
|
|
80
|
+
* each class of character taken a little wide.
|
|
81
|
+
*/
|
|
82
|
+
export function labelPx(text: string, size = 12): number {
|
|
83
|
+
let em = 0
|
|
84
|
+
for (const char of text) {
|
|
85
|
+
if (chOf(char) >= 1.4) em += 1.02
|
|
86
|
+
else if (char === ' ') em += 0.3
|
|
87
|
+
else if (/[ilIj.,:;|'!·]/.test(char)) em += 0.3
|
|
88
|
+
else if (/[mwMW@%]/.test(char)) em += 0.88
|
|
89
|
+
else if (/[A-Z]/.test(char)) em += 0.7
|
|
90
|
+
else if (/[0-9]/.test(char)) em += 0.62
|
|
91
|
+
else em += 0.58
|
|
92
|
+
}
|
|
93
|
+
return em * size
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A pseudo-random but fixed sequence, so one state always draws the same picture. */
|
|
97
|
+
function seq(seed: number): () => number {
|
|
98
|
+
let s = seed
|
|
99
|
+
return () => {
|
|
100
|
+
s = (s * 9301 + 49297) % 233280
|
|
101
|
+
return s / 233280
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- The sea: every capsule's backdrop ----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** What moves in a capsule's water. */
|
|
108
|
+
export type Sea = {
|
|
109
|
+
/** The light's colour: what the capsule is about. */
|
|
110
|
+
tone: Tone
|
|
111
|
+
/** Where 리플이 sits, px from the picture's top left: the glow and the ripples centre on it. */
|
|
112
|
+
at: { x: number; y: number }
|
|
113
|
+
/** Ripples spreading from 리플이: unhurried, or quick while a request is out. */
|
|
114
|
+
ripples?: 'calm' | 'busy'
|
|
115
|
+
/** How far the ripples reach, px (about the capsule's height and a half). */
|
|
116
|
+
reach?: number
|
|
117
|
+
/** A sonar sweep across the water: the judge sounding how deep to think. */
|
|
118
|
+
sweep?: boolean
|
|
119
|
+
/** A current along the floor: work going on. */
|
|
120
|
+
current?: boolean
|
|
121
|
+
/** Motes drifting up (the deep's plankton), quiet life at rest. */
|
|
122
|
+
motes?: boolean
|
|
123
|
+
/** A slow wave across the water at this height, px: the panels' surface line under 리플이. */
|
|
124
|
+
wave?: number
|
|
125
|
+
weather?: 'snow' | 'bubbles' | 'embers' | 'sparkles' | 'static'
|
|
126
|
+
/** A large tick drawn in at the right (done). */
|
|
127
|
+
tick?: boolean
|
|
128
|
+
/** The light's strength, 0..1 (Auto off dims it). */
|
|
129
|
+
strength?: number
|
|
130
|
+
/** How much of the picture shows, px from the top: motes and weather stay in it. */
|
|
131
|
+
visible?: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const PACE = { calm: 6.5, busy: 2.6 }
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The deep, `width` × `height` px, anchored at its top left: a violet-black gradient, a glass sheen
|
|
138
|
+
* along the top, 리플이's glow and a far cyan one, and the motion asked for.
|
|
139
|
+
*/
|
|
140
|
+
export function seaArt(width: number, height: number, sea: Sea): string {
|
|
141
|
+
const w = Math.max(80, Math.round(width))
|
|
142
|
+
const full = Math.max(20, Math.round(height))
|
|
143
|
+
// Motes, weather and sweeps live in what shows; the base, the sheen and the glows cover it all.
|
|
144
|
+
const h = Math.min(full, Math.max(20, Math.round(sea.visible ?? full)))
|
|
145
|
+
const k = sea.strength ?? 1
|
|
146
|
+
const t = TONE[sea.tone]
|
|
147
|
+
const { x: ax, y: ay } = sea.at
|
|
148
|
+
let css = ''
|
|
149
|
+
// 리플이's light reaches about as far as its ripples: a glow around it, not a wash over the water.
|
|
150
|
+
const reach = sea.reach ?? 90
|
|
151
|
+
let body =
|
|
152
|
+
'<defs>' +
|
|
153
|
+
'<linearGradient id="b" x1="0" x2="1"><stop offset="0" stop-color="' + WATER[0] + '"/><stop offset=".5" stop-color="' + WATER[1] + '"/><stop offset="1" stop-color="' + WATER[2] + '"/></linearGradient>' +
|
|
154
|
+
'<linearGradient id="s" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".055"/><stop offset=".45" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
|
|
155
|
+
'<radialGradient id="g" cx="' + r(ax) + '" cy="' + r(ay) + '" r="' + r(reach * 1.1) + '" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="' + t.glow + '" stop-opacity="' + r(0.34 * k) + '"/><stop offset=".5" stop-color="' + t.glow + '" stop-opacity="' + r(0.08 * k) + '"/><stop offset="1" stop-color="' + t.glow + '" stop-opacity="0"/></radialGradient>' +
|
|
156
|
+
'<radialGradient id="c" cx="' + r(w * 0.82) + '" cy="0" r="' + r(Math.max(140, h * 2.2)) + '" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff" stop-opacity=".035"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></radialGradient>' +
|
|
157
|
+
'</defs>' +
|
|
158
|
+
'<rect width="' + w + '" height="' + full + '" fill="url(#b)"/>' +
|
|
159
|
+
'<rect width="' + w + '" height="' + full + '" fill="url(#c)"/>' +
|
|
160
|
+
'<radialGradient id="h" cx="' + r(ax) + '" cy="' + r(ay) + '" r="34" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="' + t.hi + '" stop-opacity="' + r(0.34 * k) + '"/><stop offset="1" stop-color="' + t.hi + '" stop-opacity="0"/></radialGradient>' +
|
|
161
|
+
'<rect class="br" width="' + w + '" height="' + full + '" fill="url(#g)"/>' +
|
|
162
|
+
'<rect class="ha" width="' + w + '" height="' + full + '" fill="url(#h)"/>' +
|
|
163
|
+
'<rect width="' + w + '" height="' + full + '" fill="url(#s)"/>' +
|
|
164
|
+
'<rect y="0" width="' + w + '" height="1" fill="#fff" fill-opacity=".1"/>'
|
|
165
|
+
css += '.br{animation:br 7s ease-in-out infinite;animation-delay:-2s}@keyframes br{0%,100%{opacity:.82}50%{opacity:1}}' +
|
|
166
|
+
'.ha{animation:ha 4.2s ease-in-out infinite;animation-delay:-1s}@keyframes ha{0%,100%{opacity:.75}50%{opacity:1}}'
|
|
167
|
+
if (sea.ripples) {
|
|
168
|
+
const pace = PACE[sea.ripples]
|
|
169
|
+
const count = sea.ripples === 'busy' ? 4 : 3
|
|
170
|
+
body += '<g fill="none" stroke="' + t.hi + '">'
|
|
171
|
+
for (let i = 0; i < count; i++) body += '<circle class="rp" cx="' + r(ax) + '" cy="' + r(ay) + '" r="' + r(reach) + '" stroke-width="1.3" style="animation-delay:-' + r((pace / count) * (i + 0.4)) + 's"/>'
|
|
172
|
+
body += '</g>'
|
|
173
|
+
css += '.rp{transform-box:fill-box;transform-origin:center;opacity:0;animation:rp ' + pace + 's cubic-bezier(.15,.55,.35,1) infinite}@keyframes rp{0%{transform:scale(.08);opacity:0}10%{opacity:' + r(0.42 * k) + '}100%{transform:scale(1);opacity:0}}'
|
|
174
|
+
}
|
|
175
|
+
if (sea.motes) {
|
|
176
|
+
const rnd = seq(w % 97 + 11)
|
|
177
|
+
body += '<g>'
|
|
178
|
+
for (let i = 0; i < 14; i++) {
|
|
179
|
+
const x = ax + 30 + rnd() * (w - ax - 40)
|
|
180
|
+
const y = 3 + rnd() * (h - 6)
|
|
181
|
+
const size = 0.55 + rnd() * 0.9
|
|
182
|
+
const dur = 6 + rnd() * 7
|
|
183
|
+
body += '<circle class="mo" cx="' + r(x) + '" cy="' + r(y) + '" r="' + r(size) + '" fill="' + (rnd() > 0.7 ? t.hi : HAIR) + '" style="animation-duration:' + r(dur) + 's;animation-delay:-' + r(rnd() * dur) + 's"/>'
|
|
184
|
+
}
|
|
185
|
+
body += '</g>'
|
|
186
|
+
css += '.mo{opacity:0;animation:mo linear infinite}@keyframes mo{0%{transform:translateY(4px);opacity:0}30%{opacity:.4}70%{opacity:.25}100%{transform:translateY(-8px);opacity:0}}'
|
|
187
|
+
}
|
|
188
|
+
if (sea.wave !== undefined) {
|
|
189
|
+
const period = 140
|
|
190
|
+
// Two crests a period, drawn as quadratic halves, one above and one below the line.
|
|
191
|
+
let d = 'M' + -period + ' ' + r(sea.wave)
|
|
192
|
+
for (let x = -period, up = true; x < w + period; x += period / 2, up = !up) d += 'q' + r(period / 4) + ' ' + (up ? -5 : 5) + ' ' + r(period / 2) + ' 0'
|
|
193
|
+
body += '<path class="wv" d="' + d + '" fill="none" stroke="' + HAIR + '" stroke-opacity="' + r(0.11 * k) + '" stroke-width="1.2"/>'
|
|
194
|
+
body += '<path class="wv2" d="' + d + '" fill="none" stroke="' + t.hi + '" stroke-opacity="' + r(0.12 * k) + '" stroke-width="1" transform="translate(0 4)"/>'
|
|
195
|
+
css += '.wv{animation:wv 9s linear infinite}.wv2{animation:wv 13s linear infinite reverse}@keyframes wv{0%{transform:translateX(0)}100%{transform:translateX(' + period + 'px)}}'
|
|
196
|
+
}
|
|
197
|
+
if (sea.sweep) {
|
|
198
|
+
body += '<defs><linearGradient id="sw" x1="0" x2="1"><stop offset="0" stop-color="' + t.hi + '" stop-opacity="0"/><stop offset=".8" stop-color="' + t.hi + '" stop-opacity=".2"/><stop offset="1" stop-color="#fff" stop-opacity=".45"/></linearGradient></defs>' +
|
|
199
|
+
'<rect class="sw" x="-120" width="120" height="' + full + '" fill="url(#sw)"/>'
|
|
200
|
+
css += '.sw{animation:sw 2.2s cubic-bezier(.45,0,.25,1) infinite;animation-delay:-.3s}@keyframes sw{0%{transform:translateX(' + r(ax) + 'px)}100%{transform:translateX(' + (w + 120) + 'px)}}'
|
|
201
|
+
}
|
|
202
|
+
if (sea.current) {
|
|
203
|
+
body += '<defs><linearGradient id="cu" x1="0" x2="1"><stop offset="0" stop-color="#67e8f9" stop-opacity="0"/><stop offset=".5" stop-color="#67e8f9" stop-opacity=".9"/><stop offset="1" stop-color="' + t.hi + '" stop-opacity="0"/></linearGradient></defs>' +
|
|
204
|
+
'<rect class="cu" x="-180" y="' + (h - 1.6) + '" width="180" height="1.6" fill="url(#cu)"/>'
|
|
205
|
+
css += '.cu{animation:cu 1.7s linear infinite;animation-delay:-.6s}@keyframes cu{0%{transform:translateX(0)}100%{transform:translateX(' + (w + 180) + 'px)}}'
|
|
206
|
+
}
|
|
207
|
+
if (sea.weather) {
|
|
208
|
+
const made = weather(sea.weather, w, h, t, ax)
|
|
209
|
+
body += made.body
|
|
210
|
+
css += made.css
|
|
211
|
+
}
|
|
212
|
+
if (sea.tick) {
|
|
213
|
+
const s = Math.min(h * 0.8, 44)
|
|
214
|
+
const x = w - s * 2.2
|
|
215
|
+
const y = (h - s) / 2
|
|
216
|
+
body += '<path class="tk" d="M' + r(x) + ' ' + r(y + s * 0.55) + 'L' + r(x + s * 0.38) + ' ' + r(y + s * 0.9) + 'L' + r(x + s * 1.1) + ' ' + r(y + s * 0.08) + '" fill="none" stroke="' + t.hi + '" stroke-opacity=".3" stroke-width="' + r(s * 0.14) + '" stroke-linecap="round" stroke-linejoin="round" pathLength="1"/>'
|
|
217
|
+
css += '.tk{stroke-dasharray:1;stroke-dashoffset:1;animation:tk .9s .15s cubic-bezier(.3,.7,.3,1) forwards}@keyframes tk{to{stroke-dashoffset:0}}'
|
|
218
|
+
}
|
|
219
|
+
return svg(w, full, body, css)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function weather(kind: NonNullable<Sea['weather']>, w: number, h: number, t: Light, from: number): { body: string; css: string } {
|
|
223
|
+
const rnd = seq(kind.length * 7 + 3)
|
|
224
|
+
const start = from + 40
|
|
225
|
+
let body = ''
|
|
226
|
+
let css = ''
|
|
227
|
+
if (kind === 'snow') {
|
|
228
|
+
for (let i = 0; i < 18; i++) {
|
|
229
|
+
const x = start + rnd() * (w - start)
|
|
230
|
+
const size = 1.4 + rnd() * 2.2
|
|
231
|
+
const dur = 5 + rnd() * 5
|
|
232
|
+
const flake = rnd() > 0.5
|
|
233
|
+
? '<path d="M0 -' + r(size) + 'V' + r(size) + 'M-' + r(size * 0.87) + ' -' + r(size / 2) + 'L' + r(size * 0.87) + ' ' + r(size / 2) + 'M-' + r(size * 0.87) + ' ' + r(size / 2) + 'L' + r(size * 0.87) + ' -' + r(size / 2) + '" stroke="' + t.hi + '" stroke-width=".7" stroke-linecap="round"/>'
|
|
234
|
+
: '<circle r="' + r(size * 0.42) + '" fill="' + t.hi + '"/>'
|
|
235
|
+
body += '<g transform="translate(' + r(x) + ' 0)"><g class="fa" style="animation-duration:' + r(dur) + 's;animation-delay:-' + r(rnd() * dur) + 's;opacity:' + r(0.35 + rnd() * 0.45) + '">' + flake + '</g></g>'
|
|
236
|
+
}
|
|
237
|
+
css += '.fa{animation:fa linear infinite}@keyframes fa{0%{transform:translate(0,-8px) rotate(0)}50%{transform:translate(5px,' + r(h / 2) + 'px) rotate(90deg)}100%{transform:translate(-2px,' + r(h + 8) + 'px) rotate(180deg)}}'
|
|
238
|
+
} else if (kind === 'bubbles') {
|
|
239
|
+
for (let i = 0; i < 14; i++) {
|
|
240
|
+
const x = start + rnd() * (w - start)
|
|
241
|
+
const size = 1.2 + rnd() * 3
|
|
242
|
+
const dur = 4 + rnd() * 5
|
|
243
|
+
body += '<g transform="translate(' + r(x) + ' 0)"><circle class="ri" r="' + r(size) + '" fill="none" stroke="' + t.hi + '" stroke-width=".8" style="animation-duration:' + r(dur) + 's;animation-delay:-' + r(rnd() * dur) + 's"/></g>'
|
|
244
|
+
}
|
|
245
|
+
css += '.ri{animation:ri ease-in infinite;opacity:0}@keyframes ri{0%{transform:translate(0,' + r(h + 6) + 'px);opacity:0}20%{opacity:.6}100%{transform:translate(4px,-6px);opacity:0}}'
|
|
246
|
+
} else if (kind === 'embers') {
|
|
247
|
+
for (let i = 0; i < 18; i++) {
|
|
248
|
+
const x = start + rnd() * (w - start)
|
|
249
|
+
const size = 0.7 + rnd() * 1.5
|
|
250
|
+
const dur = 2.6 + rnd() * 3
|
|
251
|
+
body += '<g transform="translate(' + r(x) + ' 0)"><circle class="em" r="' + r(size) + '" fill="' + (rnd() > 0.5 ? t.hi : t.glow) + '" style="animation-duration:' + r(dur) + 's;animation-delay:-' + r(rnd() * dur) + 's"/></g>'
|
|
252
|
+
}
|
|
253
|
+
css += '.em{animation:em ease-out infinite;opacity:0}@keyframes em{0%{transform:translate(0,' + r(h + 4) + 'px);opacity:0}25%{opacity:.9}100%{transform:translate(-6px,-4px);opacity:0}}'
|
|
254
|
+
} else if (kind === 'sparkles') {
|
|
255
|
+
for (let i = 0; i < 10; i++) {
|
|
256
|
+
const x = start + rnd() * (w - start - 10)
|
|
257
|
+
const y = 4 + rnd() * (h - 8)
|
|
258
|
+
const size = 1.8 + rnd() * 3
|
|
259
|
+
const dur = 2.4 + rnd() * 2.4
|
|
260
|
+
body += '<g transform="translate(' + r(x) + ' ' + r(y) + ')"><path class="sp" d="M0 -' + r(size) + 'Q0 0 ' + r(size) + ' 0Q0 0 0 ' + r(size) + 'Q0 0 -' + r(size) + ' 0Q0 0 0 -' + r(size) + 'Z" fill="' + (rnd() > 0.5 ? t.hi : '#a5f3fc') + '" style="animation-duration:' + r(dur) + 's;animation-delay:-' + r(rnd() * dur) + 's"/></g>'
|
|
261
|
+
}
|
|
262
|
+
css += '.sp{transform-box:fill-box;transform-origin:center;animation:sp ease-in-out infinite;opacity:0}@keyframes sp{0%,100%{transform:scale(.2) rotate(0);opacity:0}50%{transform:scale(1) rotate(45deg);opacity:.85}}'
|
|
263
|
+
} else {
|
|
264
|
+
for (let y = 1; y < h; y += 3) body += '<rect x="' + r(start) + '" y="' + y + '" width="' + r(w - start) + '" height=".6" fill="' + t.hi + '" opacity=".1"/>'
|
|
265
|
+
body += '<rect class="sc" x="' + r(start) + '" width="' + r(w - start) + '" height="5" fill="' + t.hi + '" opacity=".14"/>'
|
|
266
|
+
css += '.sc{animation:sc 3.2s linear infinite}@keyframes sc{0%{transform:translateY(-6px)}100%{transform:translateY(' + (h + 6) + 'px)}}'
|
|
267
|
+
}
|
|
268
|
+
return { body, css }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* A plate of the water's middle shade, faded in at its left: laid behind words that take another's
|
|
273
|
+
* place (a hover tip over the reason), so the words beneath do not show through.
|
|
274
|
+
*/
|
|
275
|
+
export function plateArt(width: number, height: number): string {
|
|
276
|
+
return svg(width, height, '<defs><linearGradient id="p" x1="0" x2="1"><stop offset="0" stop-color="' + WATER[1] + '" stop-opacity="0"/><stop offset="' + r(Math.min(0.2, 14 / width)) + '" stop-color="' + WATER[1] + '"/><stop offset="1" stop-color="' + WATER[2] + '"/></linearGradient></defs><rect width="' + r(width) + '" height="' + r(height) + '" fill="url(#p)"/>')
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- Controls -----------------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
export const SWITCH_W = 36
|
|
282
|
+
export const SWITCH_H = 22
|
|
283
|
+
|
|
284
|
+
/** A switch, 36 × 22 px: a lit violet-to-cyan track with the knob right when on, a dark one when off. */
|
|
285
|
+
export function switchArt(on: boolean, busy = false): string {
|
|
286
|
+
const body = on
|
|
287
|
+
? '<defs><linearGradient id="t" x1="0" x2="1"><stop offset="0" stop-color="#7c5cff"/><stop offset="1" stop-color="#38c6ee"/></linearGradient><filter id="f" x="-30%" y="-60%" width="160%" height="220%"><feGaussianBlur stdDeviation="1.6"/></filter></defs>' +
|
|
288
|
+
'<rect x="3" y="4" width="30" height="14" rx="7" fill="#7c5cff" opacity=".55" filter="url(#f)"/>' +
|
|
289
|
+
'<rect x="2" y="2" width="32" height="18" rx="9" fill="url(#t)"/>' +
|
|
290
|
+
'<rect x="2.5" y="2.5" width="31" height="8" rx="4" fill="#fff" fill-opacity=".14"/>' +
|
|
291
|
+
'<circle class="' + (busy ? 'pu' : '') + '" cx="25" cy="11" r="7" fill="#fff"/><circle cx="25" cy="11" r="2.2" fill="#7c5cff" fill-opacity=".55"/>'
|
|
292
|
+
: '<rect x="2.5" y="2.5" width="31" height="17" rx="8.5" fill="#fff" fill-opacity=".08" stroke="' + HAIR + '" stroke-opacity=".22"/>' +
|
|
293
|
+
'<circle cx="11" cy="11" r="6.5" fill="' + SOFT + '" fill-opacity=".85"/>'
|
|
294
|
+
return svg(SWITCH_W, SWITCH_H, body, '.pu{animation:pu 1.3s ease-in-out infinite}@keyframes pu{50%{opacity:.6}}', lit(on))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The root's class on a picture of a state: `on` or `off`, so the state is in the drawing itself. */
|
|
298
|
+
function lit(on: boolean): string {
|
|
299
|
+
return ' class="' + (on ? 'on' : 'off') + '"'
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The look of a drawn button: the one thing to press, or a quiet one. */
|
|
303
|
+
export type PillLook = 'primary' | 'ghost'
|
|
304
|
+
|
|
305
|
+
/** Glyphs a button may lead with, drawn on a 12 px square. */
|
|
306
|
+
export type Glyph = 'open' | 'close' | 'gear' | 'back' | 'next' | 'copy' | 'save' | 'compact' | 'handoff'
|
|
307
|
+
|
|
308
|
+
const GLYPH: Record<Glyph, string> = {
|
|
309
|
+
open: '<path d="M7 2.2h2.8V5M9.8 2.2L5.6 6.4M8.8 7.4v2.4H2.2V3.2h2.4"/>',
|
|
310
|
+
close: '<path d="M3 3l6 6M9 3L3 9"/>',
|
|
311
|
+
gear: '<circle cx="6" cy="6" r="1.7"/><path d="M6 1.3v1.3M6 9.4v1.3M1.3 6h1.3M9.4 6h1.3M2.7 2.7l.9.9M8.4 8.4l.9.9M2.7 9.3l.9-.9M8.4 3.6l.9-.9"/><circle cx="6" cy="6" r="3.5"/>',
|
|
312
|
+
back: '<path d="M7.5 2.5L4 6l3.5 3.5"/>',
|
|
313
|
+
next: '<path d="M4.5 2.5L8 6 4.5 9.5"/>',
|
|
314
|
+
copy: '<rect x="4" y="4" width="6" height="6" rx="1.2"/><path d="M8 4V2.8A.8.8 0 0 0 7.2 2H2.8a.8.8 0 0 0-.8.8v4.4a.8.8 0 0 0 .8.8H4"/>',
|
|
315
|
+
save: '<path d="M6 1.5v9M2.5 7l3.5 3.5L9.5 7"/>',
|
|
316
|
+
compact: '<path d="M3.5 1.8L6 4.3l2.5-2.5M3.5 10.2L6 7.7l2.5 2.5M1.8 6h8.4"/>',
|
|
317
|
+
handoff: '<path d="M1.8 6h6.4M5.8 3.4L8.4 6 5.8 8.6M10.4 2.2v7.6"/>',
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A drawn button with its label (and a leading glyph) in it, 26 px high, its width whole cells so
|
|
322
|
+
* the press laid over it lines up. Returns the source and the width in cells.
|
|
323
|
+
*/
|
|
324
|
+
export function pillArt(label: string, look: PillLook, tone: Tone = 'violet', glyph?: Glyph): { source: string; cells: number } {
|
|
325
|
+
const t = TONE[tone]
|
|
326
|
+
const size = 12
|
|
327
|
+
const pad = 13
|
|
328
|
+
const gw = glyph ? 12 + 6 : 0
|
|
329
|
+
const cells = Math.ceil((labelPx(label, size) + pad * 2 + gw) / CELL_PX)
|
|
330
|
+
const w = cells * CELL_PX
|
|
331
|
+
const h = 26
|
|
332
|
+
const ink = look === 'primary' ? t.on : LABEL
|
|
333
|
+
let body = ''
|
|
334
|
+
if (look === 'primary') {
|
|
335
|
+
body +=
|
|
336
|
+
'<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="' + t.lo + '"/><stop offset="1" stop-color="' + t.hi + '"/></linearGradient></defs>' +
|
|
337
|
+
'<rect x="1" y="1" width="' + r(w - 2) + '" height="' + (h - 2) + '" rx="' + (h / 2 - 1) + '" fill="url(#g)"/>' +
|
|
338
|
+
'<rect x="2" y="1.5" width="' + r(w - 4) + '" height="' + (h / 2 - 2) + '" rx="' + (h / 4) + '" fill="#fff" fill-opacity=".16"/>'
|
|
339
|
+
} else {
|
|
340
|
+
body += '<rect x="1" y="1" width="' + r(w - 2) + '" height="' + (h - 2) + '" rx="' + (h / 2 - 1) + '" fill="#fff" fill-opacity=".06" stroke="' + HAIR + '" stroke-opacity=".24"/>'
|
|
341
|
+
}
|
|
342
|
+
const textW = labelPx(label, size)
|
|
343
|
+
const x0 = (w - textW - gw) / 2
|
|
344
|
+
if (glyph) body += '<g transform="translate(' + r(x0) + ' ' + (h / 2 - 6) + ')" fill="none" stroke="' + ink + '" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">' + GLYPH[glyph] + '</g>'
|
|
345
|
+
body += '<text x="' + r(x0 + gw + textW / 2) + '" y="' + r(h / 2 + size * 0.36) + '" text-anchor="middle" font-size="' + size + '" font-weight="' + (look === 'primary' ? 650 : 550) + '" fill="' + ink + '">' + esc(label) + '</text>'
|
|
346
|
+
return { source: svg(w, h, body), cells }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A segmented control, its segments `cells` wide each and 26 px high: a dark track, the chosen
|
|
351
|
+
* segment a lit pill in `tone`, the rest named in soft ink. `chosen` −1 lights none.
|
|
352
|
+
*/
|
|
353
|
+
export function segmentedArt(labels: readonly string[], cells: readonly number[], chosen: number, tone: Tone = 'violet', colors?: readonly string[]): string {
|
|
354
|
+
const t = TONE[tone]
|
|
355
|
+
const h = 26
|
|
356
|
+
const w = cells.reduce((n, c) => n + c, 0) * CELL_PX
|
|
357
|
+
let body =
|
|
358
|
+
'<defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="' + t.lo + '"/><stop offset="1" stop-color="' + t.hi + '" stop-opacity=".92"/></linearGradient>' +
|
|
359
|
+
'<filter id="f" x="-20%" y="-60%" width="140%" height="220%"><feGaussianBlur stdDeviation="2.2"/></filter></defs>' +
|
|
360
|
+
'<rect x=".5" y=".5" width="' + r(w - 1) + '" height="' + (h - 1) + '" rx="' + (h / 2 - 0.5) + '" fill="#fff" fill-opacity=".05" stroke="' + HAIR + '" stroke-opacity=".16"/>'
|
|
361
|
+
let x = 0
|
|
362
|
+
const starts = cells.map(c => {
|
|
363
|
+
const at = x
|
|
364
|
+
x += c * CELL_PX
|
|
365
|
+
return at
|
|
366
|
+
})
|
|
367
|
+
for (let i = 1; i < labels.length; i++) {
|
|
368
|
+
if (i === chosen || i - 1 === chosen) continue
|
|
369
|
+
body += '<rect x="' + r(starts[i]! - 0.5) + '" y="8" width="1" height="' + (h - 16) + '" fill="' + HAIR + '" fill-opacity=".14"/>'
|
|
370
|
+
}
|
|
371
|
+
if (chosen >= 0 && chosen < labels.length) {
|
|
372
|
+
const sx = starts[chosen]! + 3
|
|
373
|
+
const sw = cells[chosen]! * CELL_PX - 6
|
|
374
|
+
const fill = colors?.[chosen]
|
|
375
|
+
body +=
|
|
376
|
+
'<rect x="' + r(sx + 2) + '" y="6" width="' + r(sw - 4) + '" height="' + (h - 10) + '" rx="8" fill="' + (fill ?? t.glow) + '" opacity=".6" filter="url(#f)"/>' +
|
|
377
|
+
'<rect x="' + r(sx) + '" y="3" width="' + r(sw) + '" height="' + (h - 6) + '" rx="' + (h / 2 - 3) + '" fill="' + (fill ?? 'url(#g)') + '"/>' +
|
|
378
|
+
'<rect x="' + r(sx + 1) + '" y="3.5" width="' + r(sw - 2) + '" height="' + ((h - 6) / 2 - 1) + '" rx="' + ((h - 6) / 4) + '" fill="#fff" fill-opacity=".16"/>'
|
|
379
|
+
}
|
|
380
|
+
labels.forEach((label, i) => {
|
|
381
|
+
const lit = i === chosen
|
|
382
|
+
const cx = starts[i]! + (cells[i]! * CELL_PX) / 2
|
|
383
|
+
body += '<text x="' + r(cx) + '" y="' + r(h / 2 + 12 * 0.36) + '" text-anchor="middle" font-size="12" font-weight="' + (lit ? 650 : 500) + '" fill="' + (lit ? (colors ? '#10092e' : t.on) : SOFT) + '">' + esc(label) + '</text>'
|
|
384
|
+
})
|
|
385
|
+
return svg(w, h, body)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A stepped slider for a lean between two ends, `count` stops each `cellsPerStop` cells wide, 22 px
|
|
390
|
+
* high: a cyan-to-violet track, the middle marked, the stretch from the middle to the chosen stop
|
|
391
|
+
* lit, a white knob haloed on the chosen one.
|
|
392
|
+
*/
|
|
393
|
+
export function sliderArt(count: number, cellsPerStop: number, chosen: number): string {
|
|
394
|
+
const part = cellsPerStop * CELL_PX
|
|
395
|
+
const w = count * part
|
|
396
|
+
const h = 22
|
|
397
|
+
const at = (i: number) => part * (i + 0.5)
|
|
398
|
+
const y = h / 2
|
|
399
|
+
const mid = Math.floor(count / 2)
|
|
400
|
+
const lo = Math.min(mid, chosen)
|
|
401
|
+
const hi = Math.max(mid, chosen)
|
|
402
|
+
let body =
|
|
403
|
+
'<defs><linearGradient id="t" x1="' + r(at(0)) + '" x2="' + r(at(count - 1)) + '" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#5eead4"/><stop offset=".5" stop-color="#a5b4fc"/><stop offset="1" stop-color="#b48bff"/></linearGradient>' +
|
|
404
|
+
'<filter id="f" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="2.4"/></filter></defs>' +
|
|
405
|
+
'<rect x="' + r(at(0)) + '" y="' + (y - 2) + '" width="' + r(at(count - 1) - at(0)) + '" height="4" rx="2" fill="url(#t)" opacity=".28"/>'
|
|
406
|
+
if (hi > lo) body += '<rect x="' + r(at(lo)) + '" y="' + (y - 2) + '" width="' + r(at(hi) - at(lo)) + '" height="4" rx="2" fill="url(#t)"/>'
|
|
407
|
+
for (let i = 0; i < count; i++) {
|
|
408
|
+
if (i === chosen) continue
|
|
409
|
+
body += i === mid
|
|
410
|
+
? '<rect x="' + r(at(i) - 1) + '" y="' + (y - 5) + '" width="2" height="10" rx="1" fill="' + LABEL + '" fill-opacity=".7"/>'
|
|
411
|
+
: '<circle cx="' + r(at(i)) + '" cy="' + y + '" r="2.4" fill="' + LABEL + '" fill-opacity=".45"/>'
|
|
412
|
+
}
|
|
413
|
+
const c = at(chosen)
|
|
414
|
+
body += '<circle cx="' + r(c) + '" cy="' + y + '" r="7" fill="#9b7dff" opacity=".7" filter="url(#f)"/><circle cx="' + r(c) + '" cy="' + y + '" r="6.4" fill="#fff"/><circle cx="' + r(c) + '" cy="' + y + '" r="2.4" fill="#8b6bff"/>'
|
|
415
|
+
return svg(w, h, body)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export const WELL_PX = 32
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A native field's plate, `width` × 32 px: the one picture that follows the app's theme (through
|
|
422
|
+
* prefers-color-scheme inside it), white on a light app and the page's dark on a dark one, so the
|
|
423
|
+
* host's own field text reads on it either way.
|
|
424
|
+
*/
|
|
425
|
+
export function wellArt(width: number, height: number): string {
|
|
426
|
+
return svg(width, height,
|
|
427
|
+
'<rect class="w" x=".5" y=".5" width="' + r(width - 1) + '" height="' + r(height - 1) + '" rx="9"/>',
|
|
428
|
+
'.w{fill:#fdfcfa;stroke:#a8a49b;stroke-opacity:.7}@media (prefers-color-scheme:dark){.w{fill:#262624;stroke:#a8a49b;stroke-opacity:.45}}')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const CLOSE_PX = 22
|
|
432
|
+
|
|
433
|
+
/** The way out of a capsule, 22 px: a soft disc with a ×. */
|
|
434
|
+
export function closeArt(): string {
|
|
435
|
+
return svg(CLOSE_PX, CLOSE_PX, '<circle cx="11" cy="11" r="10" fill="#fff" fill-opacity=".07" stroke="' + HAIR + '" stroke-opacity=".2"/><path d="M7.6 7.6l6.8 6.8M14.4 7.6l-6.8 6.8" stroke="' + LABEL + '" stroke-width="1.6" stroke-linecap="round"/>')
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---- The band's instruments ---------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
export const METER_W = 24
|
|
441
|
+
export const METER_H = 16
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The effort, five bars rising shallow to deep, `lit` of them on: each in its depth's colour when
|
|
445
|
+
* Auto chose it, white when it is the person's, quiet when Auto is off, and a sonar wave running
|
|
446
|
+
* through them while the judge decides.
|
|
447
|
+
*/
|
|
448
|
+
export function meterArt(lit: number, look: 'auto' | 'manual' | 'quiet' | 'deciding'): string {
|
|
449
|
+
const defs = '<defs><filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.2"/></filter></defs>'
|
|
450
|
+
let bars = ''
|
|
451
|
+
let glow = ''
|
|
452
|
+
for (let i = 0; i < 5; i++) {
|
|
453
|
+
const bh = 5 + i * 2.75
|
|
454
|
+
const x = i * 4.8 + 0.5
|
|
455
|
+
const on = look === 'deciding' || i < lit
|
|
456
|
+
const fill = !on ? '#fff' : look === 'manual' ? '#f4f2ff' : look === 'quiet' ? '#958cc9' : DEPTH[i]!
|
|
457
|
+
const wave = look === 'deciding' ? ' class="wa" style="animation-delay:' + r(i * 0.13) + 's"' : ''
|
|
458
|
+
const shape = ' x="' + r(x) + '" y="' + r(METER_H - bh) + '" width="3.4" height="' + r(bh) + '" rx="1.2" fill="' + fill + '"'
|
|
459
|
+
bars += '<rect' + shape + ' fill-opacity="' + (on ? 1 : 0.14) + '"' + wave + '/>'
|
|
460
|
+
if (on && (look === 'auto' || look === 'deciding')) glow += '<rect' + shape + ' filter="url(#f)" opacity=".7"' + wave + '/>'
|
|
461
|
+
}
|
|
462
|
+
return svg(METER_W, METER_H, defs + glow + bars, '.wa{animation:wa 1.15s ease-in-out infinite;opacity:.3}@keyframes wa{0%,100%{opacity:.25}40%{opacity:1}}')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The context ring, 16 px: the share filled in the tone (ink while there is room). */
|
|
466
|
+
export function ringArt(percent: number, tone: Tone | 'ink'): string {
|
|
467
|
+
const s = 16
|
|
468
|
+
const rad = 6
|
|
469
|
+
const c = 2 * Math.PI * rad
|
|
470
|
+
const share = Math.max(0, Math.min(1, percent / 100))
|
|
471
|
+
// Whole percents only: the same figure is the same picture.
|
|
472
|
+
const dash = r((c * Math.round(share * 100)) / 100)
|
|
473
|
+
const stroke = tone === 'ink' ? 'url(#g)' : TONE[tone].hi
|
|
474
|
+
return svg(s, s,
|
|
475
|
+
'<defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#67e8f9"/><stop offset="1" stop-color="#a78bfa"/></linearGradient></defs>' +
|
|
476
|
+
'<circle cx="8" cy="8" r="' + rad + '" fill="none" stroke="#fff" stroke-opacity=".14" stroke-width="2.4"/>' +
|
|
477
|
+
'<circle cx="8" cy="8" r="' + rad + '" fill="none" stroke="' + stroke + '" stroke-width="2.4" stroke-linecap="round" stroke-dasharray="' + dash + ' ' + r(c) + '" transform="rotate(-90 8 8)"/>')
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The cache clock, 16 px: the life left as a pie, in the tone (soft ink when nothing holds it). */
|
|
481
|
+
export function clockArt(share: number, tone: Tone | 'soft'): string {
|
|
482
|
+
const s = 16
|
|
483
|
+
const c = 8
|
|
484
|
+
const rad = 6.4
|
|
485
|
+
const left = Math.max(0, Math.min(1, share))
|
|
486
|
+
const fill = tone === 'soft' ? SOFT : TONE[tone].hi
|
|
487
|
+
let pie = ''
|
|
488
|
+
const pr = rad - 1.8
|
|
489
|
+
if (left >= 0.999) pie = '<circle cx="' + c + '" cy="' + c + '" r="' + r(pr) + '" fill="' + fill + '"/>'
|
|
490
|
+
else if (left > 0.001) {
|
|
491
|
+
const a = (2 * Math.PI * Math.round(left * 12)) / 12
|
|
492
|
+
const x = c + pr * Math.sin(a)
|
|
493
|
+
const y = c - pr * Math.cos(a)
|
|
494
|
+
pie = '<path d="M' + c + ' ' + c + 'V' + r(c - pr) + 'A' + r(pr) + ' ' + r(pr) + ' 0 ' + (a > Math.PI ? 1 : 0) + ' 1 ' + r(x) + ' ' + r(y) + 'Z" fill="' + fill + '"/>'
|
|
495
|
+
}
|
|
496
|
+
return svg(s, s, '<circle cx="' + c + '" cy="' + c + '" r="' + rad + '" fill="none" stroke="' + fill + '" stroke-opacity=".75" stroke-width="1.3"/>' + pie)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export const ICON_PX = 16
|
|
500
|
+
|
|
501
|
+
/** Line icons of the band's controls, 16 px. */
|
|
502
|
+
export type Icon = 'flame' | 'gear' | 'agents' | 'log'
|
|
503
|
+
|
|
504
|
+
const FLAME = 'M8.1 14.3c2.8 0 4.5-1.9 4.5-4.4 0-2.8-2.4-4.2-2.8-7.2-1.7 1.1-2.6 2.8-2.5 4.3-1-.4-1.5-1.4-1.6-2.3-1.4 1.3-2 2.9-2 5.2 0 2.5 1.6 4.4 4.4 4.4z'
|
|
505
|
+
|
|
506
|
+
const ICON: Record<Icon, string> = {
|
|
507
|
+
flame: '<path d="' + FLAME + '"/>',
|
|
508
|
+
gear: '<circle cx="8" cy="8" r="2.3"/><path d="M8 1.7v1.8M8 12.5v1.8M1.7 8h1.8M12.5 8h1.8M3.5 3.5l1.3 1.3M11.2 11.2l1.3 1.3M3.5 12.5l1.3-1.3M11.2 4.8l1.3-1.3"/><circle cx="8" cy="8" r="4.8"/>',
|
|
509
|
+
agents: '<circle cx="4.8" cy="5" r="2.1"/><circle cx="11.4" cy="5" r="2.1"/><path d="M1.6 13.4c.4-2.4 1.7-3.6 3.2-3.6s2.8 1.2 3.2 3.6M8.2 13.4c.4-2.4 1.7-3.6 3.2-3.6s2.8 1.2 3.2 3.6"/>',
|
|
510
|
+
log: '<path d="M2.4 3.8h11.2M2.4 8h11.2M2.4 12.2h6.8"/>',
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* A control's icon, 16 px: soft ink at rest, lit in `tone` with a glow when on. The lit flame is
|
|
515
|
+
* filled and flickers: the cache is being kept warm.
|
|
516
|
+
*/
|
|
517
|
+
export function iconArt(icon: Icon, on: boolean, tone: Tone = 'violet'): string {
|
|
518
|
+
const t = TONE[tone]
|
|
519
|
+
const stroke = on ? t.hi : SOFT
|
|
520
|
+
if (icon === 'flame' && on) {
|
|
521
|
+
return svg(ICON_PX, ICON_PX,
|
|
522
|
+
'<defs><linearGradient id="g" x1="0" x2="0" y1="1" y2="0"><stop offset="0" stop-color="#f97316"/><stop offset=".6" stop-color="#fbbf24"/><stop offset="1" stop-color="#fde68a"/></linearGradient><filter id="f" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="1.3"/></filter></defs>' +
|
|
523
|
+
'<g class="fl"><path filter="url(#f)" opacity=".8" fill="#fb923c" d="' + FLAME + '"/><path fill="url(#g)" d="' + FLAME + '"/></g>',
|
|
524
|
+
'.fl{transform-origin:8px 14px;animation:fl 1.6s ease-in-out infinite}@keyframes fl{0%,100%{transform:scale(1,1)}50%{transform:scale(.94,1.06)}}', lit(true))
|
|
525
|
+
}
|
|
526
|
+
const glow = on ? '<defs><filter id="f" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="1.2"/></filter></defs><g filter="url(#f)" opacity=".75" fill="none" stroke="' + t.glow + '" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">' + ICON[icon] + '</g>' : ''
|
|
527
|
+
return svg(ICON_PX, ICON_PX, glow + '<g fill="none" stroke="' + stroke + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON[icon] + '</g>', '', lit(on))
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export const AGENTS_W = 26
|
|
531
|
+
|
|
532
|
+
/** The agents icon with the live count in a lit disc on its shoulder, one width whatever the count. */
|
|
533
|
+
export function agentsArt(count: number, on: boolean): string {
|
|
534
|
+
const stroke = on ? TONE.violet.hi : SOFT
|
|
535
|
+
const label = count > 9 ? '9+' : String(count)
|
|
536
|
+
const badge = count > 0
|
|
537
|
+
? '<circle cx="20" cy="5.5" r="5.5" fill="#22d3ee"/><text x="20" y="8.6" text-anchor="middle" font-size="' + (label.length > 1 ? 7 : 8.5) + '" font-weight="750" fill="#04252e">' + label + '</text>'
|
|
538
|
+
: ''
|
|
539
|
+
return svg(AGENTS_W, 16, '<g transform="translate(0 .5)" fill="none" stroke="' + stroke + '" stroke-width="1.45" stroke-linecap="round" stroke-linejoin="round">' + ICON.agents + '</g>' + badge, '', lit(on))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** A thin upright rule between the band's groups, 1 × 14 px in a 9 px slot. */
|
|
543
|
+
export function ruleArt(): string {
|
|
544
|
+
return svg(9, 16, '<rect x="4" y="1" width="1" height="14" rx=".5" fill="' + HAIR + '" fill-opacity=".16"/>')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ---- The panels' pictures -----------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
export const TILE_PX = 44
|
|
550
|
+
|
|
551
|
+
/** What a quick-settings tile pictures: its setting, as it now stands. */
|
|
552
|
+
export type TileArt =
|
|
553
|
+
| { kind: 'effort'; on: boolean }
|
|
554
|
+
| { kind: 'judge'; judge: 'haiku' | 'jev' | 'model' }
|
|
555
|
+
| { kind: 'mode'; on: boolean }
|
|
556
|
+
| { kind: 'warm'; on: boolean }
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A tile's picture, 44 px: a rounded square of its own colour with the setting drawn as it stands
|
|
560
|
+
* (rising bars sounding by themselves for automatic effort, the judge's letter, a stack of worker
|
|
561
|
+
* cards, a flame or a snowflake for keep-warm), alive while it is on.
|
|
562
|
+
*/
|
|
563
|
+
export function tileArt(art: TileArt): string {
|
|
564
|
+
const s = TILE_PX
|
|
565
|
+
const square = (top: string, bottom: string, edge: string) =>
|
|
566
|
+
'<defs><linearGradient id="q" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="' + top + '"/><stop offset="1" stop-color="' + bottom + '"/></linearGradient>' +
|
|
567
|
+
'<filter id="f" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="1.8"/></filter></defs>' +
|
|
568
|
+
'<rect x="1" y="1" width="' + (s - 2) + '" height="' + (s - 2) + '" rx="12" fill="url(#q)" stroke="' + edge + '" stroke-opacity=".45"/>' +
|
|
569
|
+
'<rect x="2" y="1.5" width="' + (s - 4) + '" height="' + (s / 2 - 2) + '" rx="10" fill="#fff" fill-opacity=".07"/>'
|
|
570
|
+
if (art.kind === 'effort') {
|
|
571
|
+
let bars = ''
|
|
572
|
+
let glow = ''
|
|
573
|
+
for (let i = 0; i < 5; i++) {
|
|
574
|
+
const bh = 7 + i * 4.2
|
|
575
|
+
const x = 9.2 + i * 5.4
|
|
576
|
+
const rect = '<rect class="' + (art.on ? 'b' + i : '') + '" x="' + r(x) + '" y="' + r(34 - bh) + '" width="3.6" height="' + r(bh) + '" rx="1.4" fill="' + (art.on ? DEPTH[i] : SOFT) + '" fill-opacity="' + (art.on ? 1 : 0.35) + '"/>'
|
|
577
|
+
bars += rect
|
|
578
|
+
if (art.on) glow += rect.replace('<rect', '<rect filter="url(#f)" opacity=".7"')
|
|
579
|
+
}
|
|
580
|
+
const sonar = art.on ? '<circle class="so" cx="22" cy="22" r="17" fill="none" stroke="#a5f3fc" stroke-width="1"/><path class="st" d="M35 8.6l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9z" fill="#fde68a"/>' : ''
|
|
581
|
+
const css = art.on
|
|
582
|
+
? '.so{transform-box:fill-box;transform-origin:center;opacity:0;animation:so 3.2s ease-out infinite;animation-delay:-1s}@keyframes so{0%{transform:scale(.2);opacity:.0}15%{opacity:.7}100%{transform:scale(1.05);opacity:0}}' +
|
|
583
|
+
[0, 1, 2, 3, 4].map(i => '.b' + i + '{transform-box:fill-box;transform-origin:bottom;animation:bb 3.2s ease-in-out infinite;animation-delay:-' + r(1 + i * 0.12) + 's}').join('') +
|
|
584
|
+
'@keyframes bb{0%,100%{transform:none}50%{transform:scaleY(.82)}}.st{transform-box:fill-box;transform-origin:center;animation:tw 2.6s ease-in-out infinite}@keyframes tw{0%,100%{opacity:.4;transform:scale(.7)}50%{opacity:1;transform:scale(1.1)}}'
|
|
585
|
+
: ''
|
|
586
|
+
return svg(s, s, square(art.on ? '#33227a' : '#34322f', art.on ? '#191146' : '#232220', art.on ? '#a78bfa' : '#8f8b82') + glow + bars + sonar, css)
|
|
587
|
+
}
|
|
588
|
+
if (art.kind === 'judge') {
|
|
589
|
+
const tone = art.judge === 'haiku' ? { top: '#5a2e1f', bottom: '#2a140d', ink: '#ffb38a', edge: '#f0a07a' } : art.judge === 'jev' ? { top: '#0f4a46', bottom: '#082624', ink: '#7ff3e0', edge: '#5eead4' } : { top: '#1f2a66', bottom: '#0f1438', ink: '#a5b4fc', edge: '#a5b4fc' }
|
|
590
|
+
const mark = art.judge === 'model'
|
|
591
|
+
? '<g fill="none" stroke="' + tone.ink + '" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="29" r="3.2"/><circle cx="31" cy="15" r="3.2"/><path d="M16 27.5c6-1.5 4-9 12-11"/></g>'
|
|
592
|
+
: '<text x="22" y="29.5" text-anchor="middle" font-size="21" font-weight="760" fill="' + tone.ink + '">' + (art.judge === 'haiku' ? 'H' : 'J') + '</text>'
|
|
593
|
+
const orbit = '<g class="or"><circle cx="22" cy="22" r="16.5" fill="none" stroke="' + tone.ink + '" stroke-opacity=".28" stroke-dasharray="2 4.2"/><circle cx="22" cy="5.5" r="1.8" fill="' + tone.ink + '"/></g>'
|
|
594
|
+
return svg(s, s, square(tone.top, tone.bottom, tone.edge) + orbit + '<g filter="url(#f)" opacity=".55">' + mark + '</g>' + mark, '.or{transform-origin:22px 22px;animation:or 14s linear infinite}@keyframes or{to{transform:rotate(360deg)}}')
|
|
595
|
+
}
|
|
596
|
+
if (art.kind === 'mode') {
|
|
597
|
+
const ink = '#fcd77a'
|
|
598
|
+
const card = (y: number, op: number, cls = '') => '<rect class="' + cls + '" x="11" y="' + y + '" width="22" height="9" rx="2.6" fill="' + ink + '" fill-opacity="' + op + '" stroke="' + ink + '" stroke-opacity="' + Math.min(1, op + 0.3) + '"/>'
|
|
599
|
+
return svg(s, s, square('#4a3410', '#24180a', '#fbbf24') + card(28, 0.18) + card(21.5, 0.32) + '<g class="fl">' + card(15, 0.95) + '<circle cx="15.5" cy="19.5" r="1.4" fill="#3a2604"/><rect x="19" y="18.6" width="10" height="1.8" rx=".9" fill="#3a2604" fill-opacity=".7"/></g>',
|
|
600
|
+
'.fl{animation:fl 4.4s ease-in-out infinite}@keyframes fl{0%,100%{transform:none}50%{transform:translateY(-1.2px)}}')
|
|
601
|
+
}
|
|
602
|
+
if (art.on) {
|
|
603
|
+
const flame = 'M22 35.5c6.4 0 10-4.2 10-9.6 0-6-5.2-9-6.1-15.4-3.6 2.4-5.6 6-5.4 9.3-2.2-.9-3.3-3-3.5-4.9-3 2.9-4.4 6.4-4.4 11.3 0 5.4 3.6 9.3 9.4 9.3z'
|
|
604
|
+
return svg(s, s,
|
|
605
|
+
square('#5a1f17', '#2a0f0b', '#fb923c') +
|
|
606
|
+
'<defs><linearGradient id="fg" x1="0" x2="0" y1="1" y2="0"><stop offset="0" stop-color="#f97316"/><stop offset=".55" stop-color="#fbbf24"/><stop offset="1" stop-color="#fef3c7"/></linearGradient></defs>' +
|
|
607
|
+
'<g class="fx"><path d="' + flame + '" fill="#fb923c" filter="url(#f)" opacity=".85"/><path d="' + flame + '" fill="url(#fg)"/><path d="M22 34c2.6 0 4.1-1.8 4.1-4 0-2.6-2.2-3.7-2.6-6.4-1.6 1.1-2.4 2.6-2.3 4-1-.4-1.4-1.3-1.5-2.1-1.3 1.2-1.9 2.7-1.9 4.6 0 2.2 1.5 3.9 4.2 3.9z" fill="#fff7e0" fill-opacity=".85"/></g>' +
|
|
608
|
+
'<circle class="sk" cx="30" cy="12" r="1.1" fill="#fde68a"/><circle class="sk sk2" cx="14" cy="14" r=".9" fill="#fdba74"/>',
|
|
609
|
+
'.fx{transform-origin:22px 35px;animation:fx 1.8s ease-in-out infinite}@keyframes fx{0%,100%{transform:scale(1,1)}35%{transform:scale(.95,1.05) skewX(-1.5deg)}70%{transform:scale(1.03,.97) skewX(1deg)}}' +
|
|
610
|
+
'.sk{animation:sk 2.4s ease-out infinite;opacity:0}.sk2{animation-delay:-1.2s}@keyframes sk{0%{transform:translateY(6px);opacity:0}30%{opacity:1}100%{transform:translateY(-6px);opacity:0}}')
|
|
611
|
+
}
|
|
612
|
+
const arm = (a: number) => '<g transform="rotate(' + a + ' 22 22)"><path d="M22 9v26M22 13l-3-3M22 13l3-3M22 31l-3 3M22 31l3 3"/></g>'
|
|
613
|
+
return svg(s, s, square('#183650', '#0c1a2a', '#7dd3fc') + '<g fill="none" stroke="#a7defb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity=".9">' + arm(0) + arm(60) + arm(120) + '</g>')
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The setup's steps as beads on a thread, 16 px high: done filled, the current one lit and larger. */
|
|
617
|
+
export function beadsArt(step: number, total: number): { source: string; width: number } {
|
|
618
|
+
const gap = 16
|
|
619
|
+
const w = gap * (total - 1) + 16
|
|
620
|
+
let body = '<rect x="8" y="7.4" width="' + (w - 16) + '" height="1.2" rx=".6" fill="' + HAIR + '" fill-opacity=".2"/>'
|
|
621
|
+
for (let i = 1; i <= total; i++) {
|
|
622
|
+
const cx = 8 + (i - 1) * gap
|
|
623
|
+
if (i < step) body += '<circle cx="' + cx + '" cy="8" r="3.6" fill="#a78bfa"/>'
|
|
624
|
+
else if (i === step) body += '<circle cx="' + cx + '" cy="8" r="7" fill="#8b6bff" opacity=".35"/><circle cx="' + cx + '" cy="8" r="4.6" fill="#fff"/><circle cx="' + cx + '" cy="8" r="2" fill="#7c5cff"/>'
|
|
625
|
+
else body += '<circle cx="' + cx + '" cy="8" r="3.4" fill="' + WATER[1] + '" stroke="' + HAIR + '" stroke-opacity=".4"/>'
|
|
626
|
+
}
|
|
627
|
+
return { source: svg(w, 16, body), width: w }
|
|
628
|
+
}
|