@bongos/core 1.19.544 → 1.19.546

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 (45) hide show
  1. package/.bongos-core.json +87 -42
  2. package/docs/adr/0227-a-task-network-map-is-refused-the-goal-graph-is-the-map.md +115 -0
  3. package/docs/copy-inventory.md +42 -18
  4. package/docs/copy-registry.json +271 -25
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/research/task-network-map/real-a-every-task.png +0 -0
  7. package/docs/research/task-network-map/real-b-dependency-forest.png +0 -0
  8. package/docs/research/task-network-map/real-c-goals.png +0 -0
  9. package/docs/research/task-network-map/real-d-joined-by-goal.png +0 -0
  10. package/modules/hall-ui/public/hall-kit.js +76 -41
  11. package/modules/hall-ui/public/profile-charts.js +8 -2
  12. package/modules/hall-ui/public/shell.js +30 -1
  13. package/modules/hall-ui/public/studio.css +268 -0
  14. package/modules/hall-ui/public/studio.html +74 -0
  15. package/modules/hall-ui/public/studio.js +213 -0
  16. package/modules/hall-ui/public/studio.states.json +54 -0
  17. package/modules/hall-ui/public/style.css +9 -0
  18. package/modules/ui-design/skills/brandkit/SKILL.md +5 -1
  19. package/modules/ui-design/skills/design-taste-frontend/SKILL.md +5 -1
  20. package/modules/ui-design/skills/design-taste-frontend-v1/SKILL.md +5 -1
  21. package/modules/ui-design/skills/gpt-taste/SKILL.md +5 -1
  22. package/modules/ui-design/skills/high-end-visual-design/SKILL.md +5 -1
  23. package/modules/ui-design/skills/image-to-code/SKILL.md +5 -1
  24. package/modules/ui-design/skills/imagegen-frontend-mobile/SKILL.md +5 -1
  25. package/modules/ui-design/skills/imagegen-frontend-web/SKILL.md +5 -1
  26. package/modules/ui-design/skills/impeccable/SKILL.md +5 -1
  27. package/modules/ui-design/skills/industrial-brutalist-ui/SKILL.md +5 -1
  28. package/modules/ui-design/skills/minimalist-ui/SKILL.md +5 -1
  29. package/modules/ui-design/skills/redesign-existing-projects/SKILL.md +5 -1
  30. package/modules/ui-design/skills/stitch-design-taste/SKILL.md +5 -1
  31. package/modules/ui-design/skills/style/SKILL.md +5 -1
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/scripts/gds/fitness.js +10 -3
  35. package/scripts/gds/skill-lint.js +43 -9
  36. package/scripts/gds/spike-task-map.js +178 -9
  37. package/src/bongos/serve-internal.js +1 -1
  38. package/src/module-api.js +1 -1
  39. package/tests/hall_kit_charts.mjs +59 -0
  40. package/tests/hall_nav.mjs +6 -1
  41. package/tests/hall_page_gate_map.mjs +5 -0
  42. package/tests/hall_studio_world.mjs +143 -0
  43. package/tests/skill_lint.mjs +51 -2
  44. package/tests/spike_task_map.mjs +0 -0
  45. package/tests/ui_design_skills.mjs +12 -2
@@ -1033,69 +1033,100 @@
1033
1033
  + '</div>';
1034
1034
  }
1035
1035
 
1036
- // calendarGridHtml(days, opts) — a year of ship-days as a contribution calendar:
1037
- // one cell per calendar day, BANDED by count rather than scaled, its reading a
1038
- // per-cell title (plus an aria summary on the whole grid), not a crosshair. The
1039
- // hall's THIRD chart family (task 1003572, criterion legibility-series-are-drawn):
1040
- // OTBKit's other two shapes are a horizontal bar series and a plot on a time axis;
1041
- // a 2-D year grid is neither, so it earns its own primitive rather than a hand-roll
1042
- // at the page (modules/hall-ui/CLAUDE.md; tests/hall_audit.mjs section 6b).
1036
+ // The calendar's date arithmetic, shared by the window and the grid.
1043
1037
  //
1044
- // THE READING, decided and written down (the task asked which): a NON-picture reader
1045
- // gets the exact figure two ways — each cell carries a `title` ("2026-09-03 · 3
1046
- // ships" / "… · no ships"), and the grid carries an aria-label summarising the
1047
- // window (active days + total ships). No table twin: unlike a line, a calendar's
1048
- // value IS its position, so a per-cell title reads the same fact the picture shows,
1049
- // and a 366-row table beneath would be longer than the thing it explains.
1050
- //
1051
- // `days`: [{ local_date:'YYYY-MM-DD', count }] — SPARSE (only days that had ships),
1052
- // already resolved in the BUILDER's timezone server-side (GET /me). Empty → '' so
1053
- // the caller renders its own empty state, never an empty grid.
1038
+ // THE TIMEZONE TRAP: local_date is already the builder's own day, so the grid is built
1039
+ // by ORDINAL arithmetic on the 'YYYY-MM-DD' parts (Date.UTC on the parsed integers,
1040
+ // floored to an epoch day) — never `new Date('2026-09-03')`, which is UTC-midnight and
1041
+ // would shift a cell a day west of Greenwich. Same epoch-day floor cumulateByDay uses,
1042
+ // so no cell moves relative to the other charts. calDow is Sunday-indexed: epoch day 0
1043
+ // (1970-01-01) is a Thursday, hence the +4.
1044
+ const calYmdToOrd = (ymd) => {
1045
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
1046
+ if (!m) return null;
1047
+ return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86400000);
1048
+ };
1049
+ const calOrdToYmd = (ord) => {
1050
+ const dt = new Date(ord * 86400000);
1051
+ return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
1052
+ };
1053
+ const calDow = (o) => (((o + 4) % 7) + 7) % 7;
1054
+
1055
+ // calendarWindow(days, opts) — the DRAWN window and what falls inside it, resolved
1056
+ // once so the grid and whatever caption sits beside it can never disagree (task
1057
+ // 1003583). Two bugs came from computing this twice: the aria summary tallied every
1058
+ // row handed in rather than the cells drawn, and profile-charts.js counted
1059
+ // `days.filter(d => d.count > 0)` over the whole bounded array — so "N days shipped
1060
+ // this year" could name a day sitting outside the grid's first partial week.
1054
1061
  //
1055
- // THE TIMEZONE TRAP the task names: local_date is already the builder's own day, so
1056
- // the grid is built by ORDINAL arithmetic on the 'YYYY-MM-DD' parts (Date.UTC on the
1057
- // parsed integers, floored to an epoch day) — never `new Date('2026-09-03')`, which
1058
- // is UTC-midnight and would shift a cell a day west of Greenwich. Same epoch-day
1059
- // floor cumulateByDay uses, so no cell moves relative to the other charts.
1060
- function calendarGridHtml(days, opts = {}) {
1062
+ // Returns null when there is nothing real to draw, so callers keep their own empty
1063
+ // state rather than rendering an empty grid.
1064
+ function calendarWindow(days, opts = {}) {
1061
1065
  const list = (days || []).filter((d) => d && typeof d.local_date === 'string');
1062
- const ymdToOrd = (ymd) => {
1063
- const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
1064
- if (!m) return null;
1065
- return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86400000);
1066
- };
1067
- const ordToYmd = (ord) => {
1068
- const dt = new Date(ord * 86400000);
1069
- return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
1070
- };
1071
1066
  const counts = new Map();
1072
1067
  let maxOrd = null;
1073
- let activeDays = 0;
1074
- let totalShips = 0;
1075
1068
  for (const d of list) {
1076
- const o = ymdToOrd(d.local_date);
1069
+ const o = calYmdToOrd(d.local_date);
1077
1070
  if (o == null) continue;
1078
1071
  const c = Math.max(0, Math.round(Number(d.count) || 0));
1079
1072
  if (c <= 0) continue;
1080
1073
  counts.set(o, (counts.get(o) || 0) + c);
1081
1074
  if (maxOrd == null || o > maxOrd) maxOrd = o;
1082
1075
  }
1083
- if (maxOrd == null) return ''; // nothing real to draw → caller's empty state
1084
- for (const [, c] of counts) { activeDays += 1; totalShips += c; }
1076
+ if (maxOrd == null) return null;
1085
1077
 
1086
1078
  // The grid ENDS at `today` (the builder's, when given) so a quiet recent week still
1087
1079
  // reads as time that passed, not a truncated chart; falls back to the last ship day.
1088
- const todayOrd = (opts.today && ymdToOrd(opts.today)) || maxOrd;
1080
+ const todayOrd = (opts.today && calYmdToOrd(opts.today)) || maxOrd;
1089
1081
  const endOrd = Math.max(todayOrd, maxOrd);
1090
1082
  const weeks = Math.max(1, Math.min(53, Math.round(Number(opts.weeks)) || 53));
1091
1083
 
1092
1084
  // Columns are weeks, rows Sun..Sat. Epoch day 0 (1970-01-01) is a Thursday, so the
1093
1085
  // Sunday-indexed weekday is (ord + 4) mod 7. The last column is the week holding
1094
1086
  // endOrd; walk back `weeks` Sundays to the first cell.
1095
- const dow = (o) => (((o + 4) % 7) + 7) % 7;
1096
- const endSun = endOrd - dow(endOrd);
1087
+ const endSun = endOrd - calDow(endOrd);
1097
1088
  const startSun = endSun - (weeks - 1) * 7;
1098
1089
 
1090
+ // Tallied over the DRAWN range only — [startSun, endOrd] — not over everything
1091
+ // handed in. A day the caller supplied that falls before the first column is real
1092
+ // history, but it is not on this picture, so it is not in this picture's summary.
1093
+ let activeDays = 0;
1094
+ let totalShips = 0;
1095
+ for (const [o, c] of counts) {
1096
+ if (o < startSun || o > endOrd) continue;
1097
+ activeDays += 1;
1098
+ totalShips += c;
1099
+ }
1100
+ return { counts, startSun, endOrd, weeks, activeDays, totalShips };
1101
+ }
1102
+
1103
+ // calendarGridHtml(days, opts) — a year of ship-days as a contribution calendar:
1104
+ // one cell per calendar day, BANDED by count rather than scaled, its reading a
1105
+ // per-cell title (plus an aria summary on the whole grid), not a crosshair. The
1106
+ // hall's THIRD chart family (task 1003572, criterion legibility-series-are-drawn):
1107
+ // OTBKit's other two shapes are a horizontal bar series and a plot on a time axis;
1108
+ // a 2-D year grid is neither, so it earns its own primitive rather than a hand-roll
1109
+ // at the page (modules/hall-ui/CLAUDE.md; tests/hall_audit.mjs section 6b).
1110
+ //
1111
+ // THE READING, decided and written down (the task asked which): a NON-picture reader
1112
+ // gets the exact figure two ways — each cell carries a `title` ("2026-09-03 · 3
1113
+ // ships" / "… · no ships"), and the grid carries an aria-label summarising the
1114
+ // window (active days + total ships). No table twin: unlike a line, a calendar's
1115
+ // value IS its position, so a per-cell title reads the same fact the picture shows,
1116
+ // and a 366-row table beneath would be longer than the thing it explains.
1117
+ //
1118
+ // `days`: [{ local_date:'YYYY-MM-DD', count }] — SPARSE (only days that had ships),
1119
+ // already resolved in the BUILDER's timezone server-side (GET /me). Empty → '' so
1120
+ // the caller renders its own empty state, never an empty grid.
1121
+ //
1122
+ // The window it draws, and the tallies its summary quotes, come from calendarWindow —
1123
+ // see the timezone trap documented there.
1124
+ function calendarGridHtml(days, opts = {}) {
1125
+ const win = calendarWindow(days, opts);
1126
+ if (!win) return ''; // nothing real to draw → caller's empty state
1127
+ const { counts, startSun, endOrd, weeks, activeDays, totalShips } = win;
1128
+ const ordToYmd = calOrdToYmd;
1129
+
1099
1130
  // level 0 = empty; 1..5 map to the anti-gaming daily cap (streak.js caps a day at
1100
1131
  // 5), so the darkest band means "hit the day's max", not "over some threshold".
1101
1132
  const level = (c) => (c <= 0 ? 0 : Math.min(5, c));
@@ -1112,8 +1143,11 @@
1112
1143
  }
1113
1144
  }
1114
1145
 
1146
+ // The window is DESCRIBED, not assumed: a caller passing weeks:6 used to be handed
1147
+ // an aria label saying "over the last year" (task 1003583).
1148
+ const span = weeks === 53 ? 'the last year' : `the last ${weeks} week${weeks === 1 ? '' : 's'}`;
1115
1149
  const summary = opts.ariaLabel
1116
- || `Ships per day over the last year: ${totalShips} ship${totalShips === 1 ? '' : 's'} across ${activeDays} day${activeDays === 1 ? '' : 's'}.`;
1150
+ || `Ships per day over ${span}: ${totalShips} ship${totalShips === 1 ? '' : 's'} across ${activeDays} day${activeDays === 1 ? '' : 's'}.`;
1117
1151
  // The legend is aria-hidden: it decorates the bands, it carries no fact the cell
1118
1152
  // titles and the summary do not already state in words.
1119
1153
  const legend = '<div class="kit-cal__key" aria-hidden="true"><span class="kit-cal__key-label">less</span>'
@@ -1141,6 +1175,7 @@
1141
1175
  sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
1142
1176
  // the year-grid contribution calendar (task 1003572) — the hall's third chart family
1143
1177
  calendarGridHtml,
1178
+ calendarWindow,
1144
1179
  // plotted charts: the axis frame + the two series shapes that ride it
1145
1180
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
1146
1181
  // plotBox is exported so a caller mapping pointer x back into viewBox
@@ -267,9 +267,15 @@
267
267
  // module doc's honest-exit rule; DONE WHEN #4).
268
268
  function calendarChart({ id, streak }) {
269
269
  const days = (streak && Array.isArray(streak.ship_days)) ? streak.ship_days : [];
270
- const grid = window.OTBKit.calendarGridHtml(days, { today: streak && streak.today });
270
+ const opts = { today: streak && streak.today };
271
+ const grid = window.OTBKit.calendarGridHtml(days, opts);
271
272
  if (!grid) return null;
272
- const active = days.filter((d) => Number(d.count) > 0).length;
273
+ // The caption counts what the GRID draws, not what the server sent (task 1003583).
274
+ // `days` is bounded to 371 days server-side while the grid spans 53 weeks back to a
275
+ // Sunday, so filtering `days` here could name a day the picture does not contain —
276
+ // and the number sits directly under that picture. One window, read twice.
277
+ const win = window.OTBKit.calendarWindow(days, opts);
278
+ const active = win ? win.activeDays : 0;
273
279
  const cur = Number(streak && streak.current) || 0;
274
280
  const lng = Number(streak && streak.longest) || 0;
275
281
  // The scalars in words, in the .pchart__read slot the other figures use for their
@@ -94,6 +94,10 @@
94
94
  leaderboard: '<path d="M3.5 15.5v-6M9 15.5V3.5m5.5 12v-9"/>',
95
95
  people: '<circle cx="6.5" cy="6.5" r="2.5"/><path d="M2.5 15c.5-2.4 2-3.7 4-3.7s3.5 1.3 4 3.7"/><circle cx="12.5" cy="7" r="2"/><path d="M12 11.5c1.8.1 3 1.3 3.5 3.3"/>',
96
96
  docs: '<path d="M4 3.5h7l3 3v10H4v-13Z"/><path d="M11 3.5v3h3"/>',
97
+ // task 1003117 (R06 of goal 1000074): the Studio (artist mode). A sprout —
98
+ // the zen-garden reading of "a place an artist wants to be", so it does not
99
+ // reuse a glyph that would read as one more docs/goals page.
100
+ studio: '<path d="M9 15.5V8.5"/><path d="M9 8.5C9 5.5 6.5 3.5 3.5 3.5c0 3 2.5 5 5.5 5Z"/><path d="M9 10C9 7.5 11.2 5.5 14 5.5c0 2.7-2.2 4.5-5 4.5Z"/>',
97
101
  settings: '<circle cx="9" cy="9" r="2.5"/><path d="M9 2.5v2m0 9v2M2.5 9h2m9 0h2M4.4 4.4l1.4 1.4m6.4 6.4 1.4 1.4m0-9.2-1.4 1.4M5.8 12.2l-1.4 1.4"/>',
98
102
  admin: '<path d="M9 2.5 15 5v4c0 3.5-2.4 6-6 7-3.6-1-6-3.5-6-7V5l6-2.5Z"/>',
99
103
  signout: '<path d="M7 3.5H4a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h3M12 12l3-3-3-3M15 9H7"/>',
@@ -153,6 +157,16 @@
153
157
  // everyone else who has a thought, which is most of the value.
154
158
  { id: 'ideas', label: 'Ideas', icon: 'goals', href: '/ideas', gate: 'signed' },
155
159
  { id: 'copy-desk', label: 'Copy desk', icon: 'docs', href: '/copy-desk', gate: 'signed', module: 'copy-desk' },
160
+ // task 1003117 (R06 of goal 1000074): the Studio — artist mode, "a place an
161
+ // artist wants to be, not a form". Beside the copy desk (the artist's other
162
+ // surface), for the same reason both sit here and not in Docs (a place you
163
+ // ACT, not reference) or You (its subject is the product, not a person).
164
+ // gate 'signed' and NOT craft-gated: an artist is a craft, not a rank, so
165
+ // hiding the artist's own room behind the artist craft would hide it from
166
+ // the artist — the exact mistake copy-desk's comment above names. NO module
167
+ // gate: it degrades to reviews alone on an instance without copy-desk,
168
+ // rather than vanishing.
169
+ { id: 'studio', label: 'Studio', icon: 'studio', href: '/studio', gate: 'signed' },
156
170
  ] },
157
171
  // Your own numbers. task 2174 folded Achievements into Standing and this task
158
172
  // retires its nav entry; VIEW_ALIASES still routes #achievements here, so old
@@ -549,6 +563,21 @@
549
563
  else delete root.dataset.social;
550
564
  }
551
565
 
566
+ // task 1003117 (R06 of goal 1000074): EMPHASISE the Studio nav entry for a
567
+ // builder whose craft includes artist — the "emphasized entry for artist-craft
568
+ // builders" half of artist mode. Cosmetic only (the page is open to every rank,
569
+ // and the server enforces nothing on craft), and craft is not a rank, so it
570
+ // reads preferred_disciplines off /me rather than the access ladder. A warm
571
+ // entry is the world's own language: the accent marks the live thing, and for
572
+ // an artist that is their room.
573
+ function applyCraftEmphasis(builder) {
574
+ var el = document.getElementById('nav-studio');
575
+ if (!el) return;
576
+ var crafts = (builder && builder.preferred_disciplines) || [];
577
+ var isArtist = Array.isArray(crafts) && crafts.indexOf('artist') !== -1;
578
+ el.classList.toggle('nav-item--craft', isArtist);
579
+ }
580
+
552
581
  // applyAccess({ authed, rank, graduated, showWork, social }) — settle nav
553
582
  // visibility and the social mode.
554
583
  // Called by builders.js on the index (it owns /me there) and by the shell's own
@@ -560,7 +589,7 @@
560
589
  if ('graduated' in state) access.graduated = !!state.graduated;
561
590
  if ('showWork' in state) access.showWork = !!state.showWork;
562
591
  if ('social' in state) applySocial(state.social);
563
- if (state.builder) renderUser(state.builder);
592
+ if (state.builder) { renderUser(state.builder); applyCraftEmphasis(state.builder); }
564
593
  }
565
594
  document.querySelectorAll('#app-sidebar [data-gate]').forEach(function (el) {
566
595
  var ok = gateAllows(el.dataset.gate);
@@ -0,0 +1,268 @@
1
+ /* modules/hall-ui/public/studio.css — the Studio (artist mode), /builders/studio.
2
+ R06 of goal 1000074 (the artist's loop), task 1003117.
3
+
4
+ The criterion (artist-mode): "a place an artist wants to be, not a form. A
5
+ focus environment on the Cloud Bongos screen — a lofi-girl-style zen garden of
6
+ focus and creativity — rather than another table of rows." So this sheet is
7
+ deliberately NOT the oversight row archetype the copy desk rides. It is a calm
8
+ room: one thing to tend at a time, generous air, tonal separation, and one
9
+ slow ambient presence — the warm "lamp" that gives a lofi room its light.
10
+
11
+ THE ONE WARM THING RULE, kept (DESIGN.md): the salmon accent lights only what
12
+ is pressable (the primary action) or LIVE. The ambient lamp is the live thing
13
+ — a slow-breathing warm presence, the room's own light — and nothing else here
14
+ is warm: the eyebrows, the step numbers and the stones are ink, never accent.
15
+
16
+ Tokens only — no colour literals (tests/hall_audit.mjs §9 holds every hall
17
+ sheet to that), so every colour is a var() or a color-mix() of one, and both
18
+ modes come for free from style.css's tier. The mode marker html[data-artist]
19
+ is set on this page's <html> (the social-mode pattern, shell.js applySocial):
20
+ entering the studio IS entering the mode, so the calm reads off the attribute. */
21
+
22
+ /* The room. Generous vertical air so the one focus sits in space, not in a list.
23
+ position:relative anchors the ambient layer below it. */
24
+ .studio {
25
+ position: relative;
26
+ padding: clamp(28px, 6vh, 76px) 0 64px;
27
+ }
28
+
29
+ /* The ambient presence — the lamp. A single soft warm disc, low and slow, the
30
+ only atmosphere the room draws (the hall's --plate ground is a deferred seam,
31
+ owner's call, so nothing is painted into it here). overflow:hidden clips the
32
+ disc to the room so it can never widen the document (the no-sideways rule). */
33
+ .studio-atmos {
34
+ position: absolute;
35
+ inset: 0;
36
+ overflow: hidden;
37
+ pointer-events: none;
38
+ z-index: 0;
39
+ }
40
+ .studio-atmos::before {
41
+ content: "";
42
+ position: absolute;
43
+ top: -24%;
44
+ right: -10%;
45
+ width: min(680px, 82vw);
46
+ aspect-ratio: 1;
47
+ border-radius: 50%;
48
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
49
+ animation: studio-lamp 38s ease-in-out infinite;
50
+ }
51
+ /* A second, cooler and fainter presence low-left, so the room has depth rather
52
+ than one lamp in a corner. --glass is the world's tonal wash token (translucent
53
+ ink on paper in light, translucent white on black in dark) — the sanctioned way
54
+ to lay a neutral wash that reads right in both modes, never a raw --ink mix into
55
+ transparent (which inverts; tests/hall_audit.mjs bans it). */
56
+ .studio-atmos::after {
57
+ content: "";
58
+ position: absolute;
59
+ bottom: -30%;
60
+ left: -14%;
61
+ width: min(560px, 72vw);
62
+ aspect-ratio: 1;
63
+ border-radius: 50%;
64
+ background: var(--glass);
65
+ animation: studio-lamp 52s ease-in-out infinite reverse;
66
+ }
67
+ /* Long-period, transform/opacity only, and dead under the shell's global
68
+ reduced-motion kill switch (style.css) — the DESIGN.md rule for ambient loops. */
69
+ @keyframes studio-lamp {
70
+ 0%, 100% { transform: scale(1); opacity: .85; }
71
+ 50% { transform: scale(1.08); opacity: 1; }
72
+ }
73
+
74
+ /* Everything the reader acts on sits above the lamp. */
75
+ .studio-head,
76
+ .studio-focus,
77
+ .studio-rest { position: relative; z-index: 1; }
78
+
79
+ /* The head: a mono eyebrow naming the mode, then the greeting as the display
80
+ voice (Manrope light, tight), then the live count as a quiet sub-line. */
81
+ .studio-head { margin-bottom: clamp(22px, 4vh, 40px); max-width: var(--measure); }
82
+ .studio-eyebrow {
83
+ margin: 0 0 10px;
84
+ font-family: var(--font-mono);
85
+ font-size: 11px;
86
+ letter-spacing: .14em;
87
+ text-transform: uppercase;
88
+ color: var(--ink-faint);
89
+ }
90
+ .studio-greet {
91
+ margin: 0 0 8px;
92
+ font-weight: 300;
93
+ font-size: clamp(28px, 4.4vw, 46px);
94
+ line-height: 1.04;
95
+ letter-spacing: -.028em;
96
+ color: var(--ink);
97
+ }
98
+ .studio-sub {
99
+ margin: 0;
100
+ font-size: 15.5px;
101
+ line-height: 1.5;
102
+ color: var(--ink-soft);
103
+ }
104
+
105
+ /* The focus — the one thing to tend now. Tone separates it from the room (the
106
+ v3 hall rule: a --bg-card panel at the full radius, no drawn edge), and it is
107
+ held to a reading width so it stays a card and not a banner. */
108
+ .studio-focus {
109
+ max-width: 720px;
110
+ background: var(--bg-card);
111
+ border-radius: var(--radius);
112
+ padding: clamp(22px, 3.2vw, 34px);
113
+ }
114
+ .studio-card__eyebrow {
115
+ margin: 0 0 14px;
116
+ font-family: var(--font-mono);
117
+ font-size: 11px;
118
+ letter-spacing: .12em;
119
+ text-transform: uppercase;
120
+ color: var(--ink-faint);
121
+ }
122
+ .studio-card__title {
123
+ margin: 0 0 14px;
124
+ font-weight: 300;
125
+ font-size: clamp(20px, 2.6vw, 26px);
126
+ line-height: 1.2;
127
+ letter-spacing: -.02em;
128
+ color: var(--ink);
129
+ }
130
+ /* The product's own words, shown as evidence for a flag — mono, wrapping
131
+ preserved (leading/trailing space is often exactly what is wrong), in a soft
132
+ tonal well so the quote reads as quotation, not as this room's own voice. */
133
+ .studio-card__quote {
134
+ display: block;
135
+ margin: 0 0 16px;
136
+ padding: 12px 14px;
137
+ border-radius: var(--radius-sm);
138
+ background: var(--glass);
139
+ font-family: var(--font-mono);
140
+ font-size: 13.5px;
141
+ line-height: 1.5;
142
+ color: var(--ink);
143
+ white-space: pre-wrap;
144
+ overflow-wrap: anywhere;
145
+ }
146
+ .studio-card__context {
147
+ margin: 0 0 18px;
148
+ font-size: 14.5px;
149
+ line-height: 1.55;
150
+ color: var(--ink-soft);
151
+ max-width: var(--measure);
152
+ }
153
+
154
+ /* The two steps of a review (R04): coach the grader, then remake or tweak. Not
155
+ a checklist widget — a gentle reminder of the shape of the work, so the room
156
+ describes the craft without becoming the form the criterion warns against. */
157
+ .studio-steps {
158
+ list-style: none;
159
+ margin: 0 0 20px;
160
+ padding: 0;
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 12px;
164
+ }
165
+ .studio-step {
166
+ display: flex;
167
+ gap: 12px;
168
+ align-items: baseline;
169
+ font-size: 14.5px;
170
+ line-height: 1.5;
171
+ color: var(--ink);
172
+ }
173
+ .studio-step__n {
174
+ flex: none;
175
+ min-width: 16px;
176
+ font-family: var(--font-mono);
177
+ font-size: 12px;
178
+ color: var(--ink-faint);
179
+ }
180
+ .studio-step small {
181
+ display: block;
182
+ margin-top: 2px;
183
+ font-size: 13px;
184
+ color: var(--ink-faint);
185
+ }
186
+
187
+ /* The one place the accent is spent on a control. Ghost is the calm pass —
188
+ "let it be for now" advances to the next piece without changing anything. */
189
+ .studio-card__actions {
190
+ display: flex;
191
+ flex-wrap: wrap;
192
+ gap: 10px;
193
+ align-items: center;
194
+ }
195
+
196
+ /* What else waits — a gentle line and a row of stones, one per remaining piece
197
+ (the zen garden), never a table. Non-interactive on purpose: the room asks the
198
+ reader to tend one thing, not to scan a queue. */
199
+ .studio-rest {
200
+ margin-top: clamp(20px, 3.5vh, 34px);
201
+ max-width: 720px;
202
+ display: flex;
203
+ flex-wrap: wrap;
204
+ align-items: center;
205
+ gap: 14px;
206
+ }
207
+ .studio-rest__line {
208
+ margin: 0;
209
+ font-size: 13.5px;
210
+ color: var(--ink-faint);
211
+ }
212
+ .studio-stones {
213
+ display: flex;
214
+ flex-wrap: wrap;
215
+ gap: 9px;
216
+ align-items: center;
217
+ }
218
+ .studio-stone {
219
+ width: 11px;
220
+ height: 11px;
221
+ border-radius: 50%;
222
+ background: var(--ink-faint);
223
+ }
224
+
225
+ /* Where the full queues live — the calm room is the front door; the tables it
226
+ replaces stay one link away for when the reader wants the whole list. */
227
+ .studio-links {
228
+ position: relative;
229
+ z-index: 1;
230
+ margin-top: clamp(26px, 4vh, 44px);
231
+ padding-top: 18px;
232
+ border-top: 1px solid var(--rule-soft);
233
+ display: flex;
234
+ flex-wrap: wrap;
235
+ gap: 8px 22px;
236
+ max-width: 720px;
237
+ font-size: 13.5px;
238
+ color: var(--ink-faint);
239
+ }
240
+ .studio-links a { color: var(--accent-ink); }
241
+
242
+ /* The empty room — a good sign, said plainly, not a dead panel. */
243
+ .studio-empty {
244
+ max-width: 720px;
245
+ background: var(--bg-card);
246
+ border-radius: var(--radius);
247
+ padding: clamp(24px, 3.4vw, 36px);
248
+ }
249
+ .studio-empty h2 {
250
+ margin: 0 0 8px;
251
+ font-weight: 300;
252
+ font-size: clamp(19px, 2.4vw, 24px);
253
+ letter-spacing: -.02em;
254
+ color: var(--ink);
255
+ }
256
+ .studio-empty p {
257
+ margin: 0;
258
+ font-size: 14.5px;
259
+ line-height: 1.55;
260
+ color: var(--ink-soft);
261
+ max-width: var(--measure);
262
+ }
263
+
264
+ /* Phone: the room keeps its air but the head and cards take the full width. */
265
+ @media (max-width: 640px) {
266
+ .studio { padding-top: 20px; }
267
+ .studio-card__actions { gap: 8px; }
268
+ }
@@ -0,0 +1,74 @@
1
+ <!doctype html>
2
+ <!-- data-artist="on" is the studio's MODE marker (shell.js applySocial's pattern,
3
+ set here statically so entering the room IS entering the mode, with no paint
4
+ flash). It is the surface half of goal 1000074's argument: the artist's craft
5
+ gets its own place, not one more table on the engineer's screen. -->
6
+ <html lang="en" data-artist="on">
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <meta name="viewport" content="width=device-width, initial-scale=1">
10
+ <title>Studio — {{worldName}}</title>
11
+ <meta name="description" content="Artist mode — a calm place to tend the words and art the project shows people, one piece at a time: the review that shipped, and the copy someone flagged.">
12
+ <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
13
+ <link rel="preconnect" href="https://fonts.googleapis.com">
14
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
16
+ <link rel="stylesheet" href="/builders/studio.css">
17
+ </head>
18
+ <body data-page="studio" data-page-title="Studio">
19
+
20
+ <div class="app">
21
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
22
+ <div class="app-scrim" id="app-scrim" hidden></div>
23
+ <div class="app-body">
24
+ <header class="app-topbar" id="app-topbar"></header>
25
+ <div class="app-content">
26
+
27
+ <!-- The studio is a room, not a board, so it carries its OWN head voice
28
+ (a greeting in the display face) rather than the quiet .page-head--board
29
+ a working table uses. The static h1 is the no-JS / signed-out fallback;
30
+ studio.js rewrites it to "Good evening, <name>" once /me answers, and
31
+ fills the sub-line with what waits. It is the page's one <h1>. -->
32
+ <main id="hall-main" class="studio">
33
+ <div class="studio-atmos" aria-hidden="true"></div>
34
+
35
+ <header class="studio-head">
36
+ <p class="studio-eyebrow">artist mode</p>
37
+ <h1 class="studio-greet" id="studio-greet">The studio</h1>
38
+ <p class="studio-sub" id="studio-sub">A quiet place to tend the words and art the project shows people.</p>
39
+ </header>
40
+
41
+ <!-- The one thing to tend now. Rendered by studio.js; the markup here is
42
+ the loading state (and the no-JS fallback), never an empty panel. -->
43
+ <section class="studio-focus" id="studio-focus" aria-live="polite">
44
+ <div class="studio-empty">
45
+ <h2>Opening the studio…</h2>
46
+ <p>Gathering the reviews and flagged copy that wait for you.</p>
47
+ </div>
48
+ </section>
49
+
50
+ <!-- What else waits — the garden's remaining stones. Hidden until there
51
+ is more than the one in focus. -->
52
+ <div class="studio-rest" id="studio-rest" hidden></div>
53
+
54
+ <!-- The full tables the room replaces stay one link away, for when the
55
+ reader wants the whole list rather than one piece at a time. -->
56
+ <nav class="studio-links" aria-label="The full queues">
57
+ <a href="/builders/copy-desk">The copy desk — every flagged string</a>
58
+ <a href="/builders/work">The task board — all work</a>
59
+ </nav>
60
+ </main>
61
+
62
+ <footer class="app-foot" id="app-foot"></footer>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
68
+
69
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
70
+ <script src="/builders/palette.js" defer></script>
71
+ <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
72
+ <script src="/builders/studio.js?v=2026-09-04-studio1"></script>
73
+ </body>
74
+ </html>