M2 Cycle 8 — UI Scale, Bible Live Filter & Neon Restyle
M2 Cycle 8 — UI Scale, Bible Live Filter & Neon Restyle
Section titled “M2 Cycle 8 — UI Scale, Bible Live Filter & Neon Restyle”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make game HUD/weapon-panel elements larger, mark in-game bible content with a live filter, and restyle the bible tool to match the game’s neon aesthetic.
Architecture: T1 is pure GDScript constant changes (no logic). T2 adds live: true to 14 seed entries (spec says 13 but lists 14 — follow the list). T3 wires a filter toggle and badge in app.js/list.js. T4 is CSS-only. T3 depends on T2’s seed data; all others are independent.
Tech Stack: Godot 4.6.3 GDScript; vanilla ES-module JavaScript (no bundler); CSS3
Global Constraints
Section titled “Global Constraints”- Godot tests:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit— must pass 150 tests, 0 failures - Bible tests:
cd tools/design-bible && node --test— must pass 31 tests, 0 failures - GDScript: typed, no untyped var; no Engine/Time/Node APIs inside
/simfiles - No new GDScript tests for T1 (render-only). Existing
hp_fill_width()test asserts> 230.0— new BAR_W is 334.0, still passes assert_push_error_count(0)is the correct GUT 9.6 API (notassert_no_push_errors())- Determinism hashes unchanged (T1 doesn’t touch sim):
snapshot_string().hash() = 1314757315,state_checksum() = 1949813464 - YAGNI:
_tint_borderstub in weapon_panel.gd stays as-is
Task 1: Game UI Scale Pass
Section titled “Task 1: Game UI Scale Pass”Files:
- Modify:
ui/hud.gd - Modify:
ui/weapon_panel.gd
Interfaces:
-
Consumes:
NeonTheme.CYAN,NeonTheme.TEXT,NeonTheme.mono_font(),NeonTheme.title_font()— all unchanged -
Produces:
hp_fill_width() -> floatreturnsBAR_W * fracwhere BAR_W is now 334.0 -
Step 1: Baseline smoke
godot --headless --path . --quit-after 300 2>&1 | grep "SCRIPT ERROR" | head -5echo "smoke exit: $?"Expected: no SCRIPT ERROR, exit 0.
- Step 2: Replace
ui/hud.gd
Changes: BAR_W 234→334, BAR_H 32→40, backing 240×40→340×52, fill inset (19,16)→(21,18), HP font 15→18, level x 264→364 (16+340+8=364), level size 80×40→100×52, level font 18→22, timer x offset -80→-110, timer width 160→220, timer font 28→38, kills x -160→-196, kills width 144→180, kills font 18→22.
class_name Hudextends CanvasLayer
const BAR_W: float = 334.0 # usable fill width inside 340px backingconst BAR_H: float = 40.0
var _hp_fill: ColorRectvar _hp_label: Labelvar _level_label: Labelvar _timer_label: Labelvar _kills_label: Label
func _ready() -> void: var vp_w: float = ProjectSettings.get_setting("display/window/size/viewport_width", 1152) var vp: Vector2 = Vector2(float(vp_w), 648.0)
# ── HP bar (top-left) ────────────────────────────────────────────── var bar_backing := ColorRect.new() bar_backing.position = Vector2(16, 12) bar_backing.size = Vector2(340, 52) bar_backing.color = Color(0, 0, 0, 0.55) bar_backing.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(bar_backing)
_hp_fill = ColorRect.new() _hp_fill.position = Vector2(21, 18) # 5px inset _hp_fill.size = Vector2(BAR_W, BAR_H) _hp_fill.color = NeonTheme.CYAN _hp_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE bar_backing.add_child(_hp_fill)
_hp_label = Label.new() _hp_label.position = Vector2(0, 0) _hp_label.size = Vector2(340, 52) _hp_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _hp_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _hp_label.add_theme_font_override("font", NeonTheme.mono_font()) _hp_label.add_theme_font_size_override("font_size", 18) _hp_label.add_theme_color_override("font_color", NeonTheme.TEXT) _hp_label.mouse_filter = Control.MOUSE_FILTER_IGNORE bar_backing.add_child(_hp_label)
# ── Level badge (right of HP bar) ────────────────────────────────── _level_label = Label.new() _level_label.position = Vector2(364, 12) _level_label.size = Vector2(100, 52) _level_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _level_label.add_theme_font_override("font", NeonTheme.title_font()) _level_label.add_theme_font_size_override("font_size", 22) _level_label.add_theme_color_override("font_color", NeonTheme.CYAN) _level_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_level_label)
# ── Run timer (top-centre) ───────────────────────────────────────── _timer_label = Label.new() _timer_label.position = Vector2(vp.x / 2.0 - 110, 10) _timer_label.size = Vector2(220, 40) _timer_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _timer_label.add_theme_font_override("font", NeonTheme.title_font()) _timer_label.add_theme_font_size_override("font_size", 38) _timer_label.add_theme_color_override("font_color", NeonTheme.CYAN) _timer_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_timer_label)
# ── Kill counter (top-right) ─────────────────────────────────────── _kills_label = Label.new() _kills_label.position = Vector2(vp.x - 196, 12) _kills_label.size = Vector2(180, 40) _kills_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _kills_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _kills_label.add_theme_font_override("font", NeonTheme.mono_font()) _kills_label.add_theme_font_size_override("font_size", 22) _kills_label.add_theme_color_override("font_color", NeonTheme.CYAN) _kills_label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_kills_label)
func update_hud(sim: Sim) -> void: var frac: float = clampf(sim.player.hp / maxf(sim.player.max_hp, 1.0), 0.0, 1.0) _hp_fill.size.x = BAR_W * frac _hp_fill.color = _hp_color(frac) _hp_label.text = "HP %d / %d" % [int(sim.player.hp), int(sim.player.max_hp)] _level_label.text = "Lv %d" % sim.player.level var t := int(sim.run_time) _timer_label.text = "%02d:%02d" % [t / 60, t % 60] _kills_label.text = "x %d" % sim.kills
# Test seams ──────────────────────────────────────────────────────────────func hp_fill_width() -> float: return _hp_fill.size.x
func hp_fill_color() -> Color: return _hp_fill.color
# Private ─────────────────────────────────────────────────────────────────func _hp_color(frac: float) -> Color: if frac > 0.6: return NeonTheme.CYAN if frac > 0.3: var t: float = (frac - 0.3) / 0.3 return Color(1.0, 0.85, 0.1).lerp(NeonTheme.CYAN, t) return Color(1.0, 0.25, 0.2)- Step 3: Replace
ui/weapon_panel.gd
Changes: arc radius 24→32, arc line width 3→4, SLOT_W 120→160, SLOT_H 70→90, SLOT_GAP 12→16, name height 20→24, name font 13→16, stat y-offset 28→34, stat width SLOT_W-44→SLOT_W-50, stat height 18→20, stat font 12→15, arc x offset SLOT_W-30→SLOT_W-38.
class_name WeaponPanelextends CanvasLayer
# ── Inner: cooldown arc drawn via _draw() ────────────────────────────────class _CooldownArc extends Node2D: var frac: float = 0.0 var col: Color = NeonTheme.CYAN func _draw() -> void: if frac <= 0.0: return draw_arc(Vector2.ZERO, 32.0, -PI / 2.0, -PI / 2.0 + TAU * frac, 48, col, 4.0, true)
const SLOT_W: float = 160.0const SLOT_H: float = 90.0const SLOT_GAP: float = 16.0
var _name_labels: Array[Label] = []var _stat_labels: Array[Label] = []var _arcs: Array[_CooldownArc] = []
func _ready() -> void: var vp_w: float = float(ProjectSettings.get_setting("display/window/size/viewport_width", 1152)) var vp_h: float = float(ProjectSettings.get_setting("display/window/size/viewport_height", 648)) var x0: float = vp_w / 2.0 - SLOT_W - SLOT_GAP / 2.0 var y0: float = vp_h - SLOT_H - 16.0 for i in range(2): var sx: float = x0 + i * (SLOT_W + SLOT_GAP) _build_slot(sx, y0, i)
func _build_slot(x: float, y: float, _idx: int) -> void: var backing := ColorRect.new() backing.position = Vector2(x, y) backing.size = Vector2(SLOT_W, SLOT_H) backing.color = Color(0, 0, 0, 0.55) backing.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(backing)
var border := Panel.new() border.position = Vector2(x, y) border.size = Vector2(SLOT_W, SLOT_H) border.mouse_filter = Control.MOUSE_FILTER_IGNORE var sb := StyleBoxFlat.new() sb.bg_color = Color.TRANSPARENT sb.border_width_left = 1; sb.border_width_right = 1 sb.border_width_top = 1; sb.border_width_bottom = 1 sb.border_color = NeonTheme.CYAN sb.corner_radius_top_left = 8; sb.corner_radius_top_right = 8 sb.corner_radius_bottom_left = 8; sb.corner_radius_bottom_right = 8 border.add_theme_stylebox_override("panel", sb) add_child(border)
var name_lbl := Label.new() name_lbl.position = Vector2(x + 8, y + 6) name_lbl.size = Vector2(SLOT_W - 16, 24) name_lbl.add_theme_font_override("font", NeonTheme.title_font()) name_lbl.add_theme_font_size_override("font_size", 16) name_lbl.add_theme_color_override("font_color", NeonTheme.TEXT) name_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(name_lbl) _name_labels.append(name_lbl)
var stat_lbl := Label.new() stat_lbl.position = Vector2(x + 8, y + 34) stat_lbl.size = Vector2(SLOT_W - 50, 20) stat_lbl.add_theme_font_override("font", NeonTheme.mono_font()) stat_lbl.add_theme_font_size_override("font_size", 15) stat_lbl.add_theme_color_override("font_color", NeonTheme.CYAN) stat_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(stat_lbl) _stat_labels.append(stat_lbl)
var arc := _CooldownArc.new() arc.position = Vector2(x + SLOT_W - 38, y + SLOT_H / 2.0) add_child(arc) _arcs.append(arc)
func update_panel( pulse: WeaponPulse, nova: WeaponNova, pulse_el: int, nova_el: int, content: ContentDB, dmg_mult: float) -> void: _name_labels[0].text = "Lightning" _stat_labels[0].text = "dmg %.0f" % (pulse.base_damage * dmg_mult) _arcs[0].frac = pulse.cooldown_frac() _arcs[0].col = ElementPalette.color_for(content, pulse_el) _arcs[0].queue_redraw() _tint_border(0, ElementPalette.color_for(content, pulse_el))
_name_labels[1].text = "Fire Nova" _stat_labels[1].text = "dmg %.0f r%.0f" % [nova.base_damage * dmg_mult, nova.area] _arcs[1].frac = nova.cooldown_frac() _arcs[1].col = ElementPalette.color_for(content, nova_el) _arcs[1].queue_redraw() _tint_border(1, ElementPalette.color_for(content, nova_el))
func _tint_border(_slot_idx: int, _col: Color) -> void: pass # Element tinting via arc colour is sufficient; border stays cyan- Step 4: Run full suite + smoke
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -5Expected: 150 passed, 0 failed (hp_fill_width() asserts > 230.0; new BAR_W 334.0 passes).
godot --headless --path . --quit-after 300 2>&1 | grep "SCRIPT ERROR" | head -5Expected: no SCRIPT ERROR lines.
- Step 5: Commit
git add ui/hud.gd ui/weapon_panel.gdgit commit -m "feat: M2 cycle 8 — UI scale pass (1.4× HUD and weapon-panel constants)"Task 2: Bible Live Flags
Section titled “Task 2: Bible Live Flags”Files:
- Modify:
tools/design-bible/src/seed.js
Interfaces:
- Consumes: nothing from earlier tasks
- Produces:
live: trueon 14 entries — consumed by T3’sfiltered()and list badge
Note: spec header says “13 entries” but the itemised list totals 14 (2 weapons + 1 enemy + 8 mods + 2 elements + 1 reaction). Follow the list.
- Step 1: Verify baseline
cd ~/Claude/bullet-heaven && node --test tools/design-bible/src/ 2>/dev/null; cd tools/design-bible && node --test 2>&1 | tail -3Expected: 31 pass.
- Step 2: Replace
tools/design-bible/src/seed.js
weapon() and enemy() have an extra spread param — add live: true inside the extra object.
el(), mod(), rx() have no spread — wrap with Object.assign(fn(...), { live: true }).
// Helper to keep element rows terse: id,name,color,decay,status,base,stacksMax,scaleconst el = (id, name, color, status, status_base = 2) => ({ id, name, color, aura_decay_s: 4, status, status_base, stacks_max: 6, per_stack_scale: 1.15, tags: ['element', id] });
const elements = [ Object.assign(el('fire', 'Fire', '#ff6a4d', 'burn'), { live: true }), el('cold', 'Cold', '#6cc8ff', 'chill'), Object.assign(el('lightning', 'Lightning', '#ffe34d', 'shock', 0.15), { live: true }), el('poison', 'Poison', '#8cff6a', 'toxin'), el('void', 'Void', '#b06aff', 'gravity'), el('aether', 'Aether', '#e0e0ff', 'echo'), el('kinetic', 'Kinetic', '#c9d1d9', 'impact'), el('light', 'Light', '#fff3a0', 'mark'), el('decay', 'Decay', '#8a7a5c', 'wither'), el('time', 'Time', '#a0e0ff', 'timewarp'), el('sound', 'Sound', '#ff9ee0', 'resonate'), el('blood', 'Blood', '#ff5a5a', 'hemorrhage'), el('psychic', 'Psychic', '#c08aff', 'charm'), el('nano', 'Nano', '#6affd0', 'contagion'),];
const rx = (aura, applied, name, effect, base_magnitude = 30) => ({ id: `${aura}-${applied}`, name, aura, applied, effect, base_magnitude, per_stack_scale: 1.15, consumes_aura: true, notes: '' });
const reactions = [ rx('cold', 'lightning', 'Superconduct', 'shatter', 40), rx('lightning', 'cold', 'Superconduct', 'shatter', 40), rx('fire', 'cold', 'Thermal Shock', 'burst', 35), rx('cold', 'fire', 'Thermal Shock', 'burst', 35), Object.assign(rx('fire', 'lightning', 'Plasma', 'burst', 45), { live: true }), rx('lightning', 'fire', 'Plasma', 'burst', 45), rx('fire', 'poison', 'Combustion', 'spread', 30), rx('poison', 'lightning', 'Electrolysis', 'spread', 30), rx('fire', 'void', 'Collapse', 'pull', 50), rx('cold', 'void', 'Absolute Zero', 'cc', 40), rx('poison', 'void', 'Blight Implosion', 'pull', 35), rx('cold', 'sound', 'Shatter', 'shatter', 55), rx('void', 'aether', 'Paradox', 'special', 60),];
const weapon = (id, name, archetype, element, base_damage, cooldown_s, extra = {}) => ({ id, name, archetype, element, base_damage, cooldown_s, projectile_speed: 520, projectile_radius: 6, lifetime_s: 1.4, projectile_count: 1, area: 0, pierce: 0, level_max: 8, evolution: '', tags: [archetype], ...extra });
const weapons = [ weapon('pulse', 'Pulse', 'projectile', 'lightning', 1.0, 0.6, { tags: ['projectile','homing'], live: true }), weapon('orbit', 'Orbit Shards', 'orbital', 'cold', 0.8, 0.0, { projectile_count: 3, tags: ['orbital'] }), weapon('beam', 'Beam', 'beam', 'light', 0.4, 0.1, { pierce: 99, tags: ['beam','pierce'] }), weapon('nova', 'Nova', 'area', 'fire', 3.0, 2.0, { area: 180, tags: ['area'], live: true }), weapon('turret', 'Turret', 'summon', 'kinetic', 0.7, 0.4, { tags: ['summon','trap'] }),];
const mod = (id, name, kind, effect, magnitude, applies = []) => ({ id, name, kind, effect, magnitude, applies_to_tags: applies, tags: [] });
const mods = [ Object.assign(mod('damage', 'Sharpened', 'stat', 'damage_mult', 1.25), { live: true }), Object.assign(mod('fire-rate', 'Overclock', 'stat', 'fire_rate_mult', 1.20), { live: true }), Object.assign(mod('move-speed', 'Thrusters', 'stat', 'move_speed', 1.12), { live: true }), Object.assign(mod('pickup', 'Magnet Field', 'stat', 'pickup_radius', 1.30), { live: true }), Object.assign(mod('max-hp', 'Plating', 'stat', 'max_hp', 25), { live: true }), mod('crit', 'Focus', 'stat', 'crit_chance', 0.15), mod('pierce', 'Penetrator', 'transformative', 'projectiles_pierce', 1, ['projectile']), mod('split', 'Fork', 'transformative', 'split_on_hit', 2, ['projectile']), Object.assign(mod('overcharge', 'Overcharge', 'transformative', 'stack_bonus', 1), { live: true }), Object.assign(mod('catalyst', 'Catalyst', 'transformative', 'reaction_damage_mult', 1.5), { live: true }), Object.assign(mod('lingering', 'Lingering', 'transformative', 'aura_duration_mult', 1.5), { live: true }),];
const enemy = (id, name, archetype, hp, speed, contact_damage, xp_value, extra = {}) => ({ id, name, archetype, hp, speed, radius: 14, contact_damage, xp_value, armor: 0, tags: [archetype], ...extra });
const enemies = [ enemy('swarmer', 'Swarmer', 'swarmer', 3, 70, 12, 1, { live: true }), enemy('tank', 'Tank', 'tank', 30, 40, 18, 4, { radius: 22, armor: 4 }), enemy('shooter', 'Shooter', 'ranged', 8, 55, 10, 3), enemy('splitter', 'Splitter', 'splitter', 10, 60, 10, 3), enemy('elite', 'Elite', 'charger', 60, 90, 25, 12, { radius: 26, armor: 6 }),];
const tagVocab = [ ['projectile','delivery'],['orbital','delivery'],['beam','delivery'],['area','delivery'], ['melee','delivery'],['summon','delivery'],['trap','delivery'],['channeled','delivery'], ['fire','element'],['cold','element'],['lightning','element'],['poison','element'], ['void','element'],['aether','element'],['kinetic','element'],['light','element'], ['decay','element'],['time','element'],['sound','element'],['blood','element'], ['psychic','element'],['nano','element'],['element','trait'], ['crit','trait'],['pierce','trait'],['chain','trait'],['homing','trait'],['dot','trait'],['cc','trait'],].map(([name, group]) => ({ id: name, name, group }));
export const SEED = { schemaVersion: 1, data: { elements, reactions, weapons, mods, evolutions: [], enemies, elite_affixes: [], bosses: [], characters: [], items: [], pickups: [], meta_upgrades: [], unlocks: [], ascension_tiers: [], run_structure: [], tags: tagVocab, },};- Step 3: Run bible tests
cd tools/design-bible && node --test 2>&1 | tail -3Expected: 31 pass. The live field is extra data the pure-logic core ignores.
- Step 4: Commit
git add tools/design-bible/src/seed.jsgit commit -m "feat: mark 14 in-game bible entries live (weapons, enemy, mods, elements, reaction)"Task 3: Bible Live Filter + Badge
Section titled “Task 3: Bible Live Filter + Badge”Files:
- Modify:
tools/design-bible/src/app.js - Modify:
tools/design-bible/src/views/list.js
Interfaces:
-
Consumes: T2’s
live: truefield on entries viae.livecheck -
Produces:
button[data-live="true/false"]attribute (styled gold by T4),.live-labelclass on spans (styled gold by T4) -
Step 1: Replace
tools/design-bible/src/app.js
Three changes from current: (1) liveOnly: true added to ui object on line 17; (2) filtered() filters by e.live when ui.liveOnly; (3) renderActions() prepends a live-toggle button with dataset.live.
import { CATEGORIES, categoryByKey } from './schema.js';import { SEED } from './seed.js';import { BibleModel } from './model.js';import { save, load, exportJSON, importJSON } from './persistence.js';import { renderNav } from './views/nav.js';import { renderList } from './views/list.js';import { renderDetail } from './views/detail.js';import { renderMatrix } from './views/matrix.js';import { renderTable } from './views/table.js';
const navEl = document.getElementById('nav');const listEl = document.getElementById('list');const detailEl = document.getElementById('detail');const actionsEl = document.getElementById('actions');
let model = new BibleModel(load(localStorage, SEED));const ui = { activeKey: 'elements', activeId: null, query: '', viewMode: 'detail', liveOnly: true };
function persist() { save(localStorage, model.bible); }
function selectCategory(key) { ui.activeKey = key; ui.activeId = null; ui.query = ''; render(); }function selectEntry(id) { ui.activeId = id; render(); }
function filtered() { const entries = model.list(ui.activeKey); return ui.liveOnly ? entries.filter(e => e.live) : entries;}
function renderActions() { actionsEl.innerHTML = ''; const mk = (label, fn) => { const b = document.createElement('button'); b.textContent = label; b.onclick = fn; b.style.marginLeft = '6px'; return b; };
const liveBtn = mk(ui.liveOnly ? '◈ Live' : '◇ All', () => { ui.liveOnly = !ui.liveOnly; render(); }); liveBtn.title = ui.liveOnly ? 'Showing live items only — click to show all' : 'Showing all items — click to show live only'; liveBtn.dataset.live = ui.liveOnly ? 'true' : 'false';
const toggle = mk(ui.viewMode === 'detail' ? 'Table view' : 'Detail view', () => { ui.viewMode = ui.viewMode === 'detail' ? 'table' : 'detail'; render(); }); const exp = mk('Export', () => { const blob = new Blob([exportJSON(model.bible)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'bullet-heaven-bible.json'; a.click(); }); const imp = mk('Import', () => fileInput.click()); const reset = mk('Reset', () => { if (confirm('Reset to seed? Unsaved edits lost.')) { model = new BibleModel(SEED); ui.activeId = null; persist(); render(); } }); actionsEl.append(liveBtn, toggle, exp, imp, reset);}
const fileInput = document.createElement('input');fileInput.type = 'file'; fileInput.accept = 'application/json'; fileInput.style.display = 'none';fileInput.onchange = () => { const f = fileInput.files[0]; if (!f) return; const reader = new FileReader(); reader.onload = () => { try { model = new BibleModel(importJSON(reader.result)); persist(); render(); } catch (e) { alert('Import failed: ' + e.message); } fileInput.value = ''; }; reader.readAsText(f);};document.body.appendChild(fileInput);
function render() { renderActions(); renderNav(navEl, { categories: CATEGORIES.map(c => ({ key: c.key, label: c.label })), activeKey: ui.activeKey, onSelect: selectCategory });
const cat = categoryByKey(ui.activeKey);
if (ui.activeKey === 'reactions') { renderMatrix(listEl, { model, onPick: (aura, applied) => { const id = `${aura}-${applied}`; if (!model.get('reactions', id)) { const e = model.add('reactions'); e.id = id; e.aura = aura; e.applied = applied; e.name = `${aura}+${applied}`; persist(); } selectEntry(id); }}); const entry = model.get('reactions', ui.activeId); renderDetail(detailEl, { category: cat, entry, model, onChange: onFieldChange }); return; }
if (ui.viewMode === 'table') { renderTable(listEl, { category: cat, entries: filtered(), model, onChange: (id, field, value) => { model.setField(ui.activeKey, id, field, value); persist(); render(); }, onSelect: (id) => { ui.viewMode = 'detail'; selectEntry(id); } }); detailEl.innerHTML = ''; return; }
renderList(listEl, { entries: filtered(), activeId: ui.activeId, query: ui.query, onQuery: (q) => { ui.query = q; render(); }, onSelect: selectEntry, onAdd: () => { const e = model.add(ui.activeKey); persist(); selectEntry(e.id); }, onDuplicate: (id) => { const e = model.duplicate(ui.activeKey, id); persist(); selectEntry(e.id); }, onRemove: (id) => { model.remove(ui.activeKey, id); if (ui.activeId === id) ui.activeId = null; persist(); render(); }, }); const entry = model.get(ui.activeKey, ui.activeId); renderDetail(detailEl, { category: cat, entry, model, onChange: onFieldChange });}
function onFieldChange(field, value) { if (!ui.activeId) return; model.setField(ui.activeKey, ui.activeId, field, value); persist(); render();}
render();- Step 2: Replace
tools/design-bible/src/views/list.js
Add ◈ prefix and live-label class to the label span. Only the label block changes (lines 22-24 of original):
export function renderList(container, opts) { const { entries, activeId, onSelect, onAdd, onDuplicate, onRemove, query, onQuery } = opts; container.innerHTML = '';
const search = document.createElement('input'); search.placeholder = 'search…'; search.value = query ?? ''; search.oninput = () => onQuery(search.value); container.appendChild(search);
const add = document.createElement('button'); add.textContent = '+ Add'; add.style.margin = '8px 0'; add.onclick = () => onAdd(); container.appendChild(add);
const q = (query ?? '').toLowerCase(); for (const e of entries) { if (q && !(e.name ?? '').toLowerCase().includes(q) && !(e.id ?? '').includes(q)) continue; const row = document.createElement('div'); row.className = 'listitem' + (e.id === activeId ? ' active' : ''); const label = document.createElement('span'); label.textContent = (e.live ? '◈ ' : '') + (e.name ?? e.id); label.style.cursor = 'pointer'; if (e.live) label.classList.add('live-label'); label.onclick = () => onSelect(e.id); const dup = document.createElement('button'); dup.textContent = '⧉'; dup.title = 'duplicate'; dup.style.float = 'right'; dup.onclick = () => onDuplicate(e.id); const del = document.createElement('button'); del.textContent = '✕'; del.style.float = 'right'; del.onclick = () => onRemove(e.id); row.append(label, del, dup); container.appendChild(row); }}- Step 3: Run bible tests
cd tools/design-bible && node --test 2>&1 | tail -3Expected: 31 pass — app.js and list.js are UI code, not in the node-testable core.
- Step 4: Commit
git add tools/design-bible/src/app.js tools/design-bible/src/views/list.jsgit commit -m "feat: bible live filter — defaults live-only, toggle button, ◈ badge on live items"Task 4: Bible Neon Restyle
Section titled “Task 4: Bible Neon Restyle”Files:
- Modify:
tools/design-bible/index.html - Modify:
tools/design-bible/css/style.css
Interfaces:
-
Consumes: T3’s
button[data-live="true"]attribute and.live-labelclass — styles them gold -
Produces: visual restyle only
-
Step 1: Replace
tools/design-bible/index.html
Add Google Fonts preconnect + stylesheet before css/style.css. No other changes.
<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Bullet Heaven — Design Bible</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"> <link rel="stylesheet" href="css/style.css"></head><body> <header id="topbar"><span class="brand">◇ Design Bible</span><span id="actions"></span></header> <main id="layout"> <nav id="nav"></nav> <section id="list"></section> <section id="detail"></section> </main> <script type="module" src="src/app.js"></script></body></html>- Step 2: Replace
tools/design-bible/css/style.css
:root { --bg:#0a0a12; --panel:#12121f; --line:#2a2a44; --text:#e6e6f0; --accent:#6cc8ff; --muted:#8a8ab0; }* { box-sizing: border-box; }body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 'JetBrains Mono',monospace; }#topbar { display:flex; justify-content:space-between; align-items:center; padding:8px 14px; background:linear-gradient(180deg,#14141f 0%,#0a0a12 100%); border-bottom:1px solid #1e1e38; }.brand { color:var(--accent); font-family:'Orbitron',sans-serif; font-size:15px; letter-spacing:0.08em; text-shadow:0 0 12px rgba(108,200,255,0.5); }#layout { display:grid; grid-template-columns:200px 280px 1fr; height:calc(100vh - 41px); }#nav,#list,#detail { overflow:auto; padding:10px; }#nav,#list { border-right:1px solid var(--line); }.navitem,.listitem { padding:6px 8px; border-radius:6px; cursor:pointer; }.navitem:hover,.listitem:hover { background:var(--panel); }.navitem.active,.listitem.active { background:var(--panel); color:var(--accent); border-left:2px solid var(--accent); padding-left:6px; box-shadow:inset 0 0 10px rgba(108,200,255,0.06); }button { background:var(--panel); color:var(--text); border:1px solid var(--line); border-radius:6px; padding:5px 10px; cursor:pointer; font-family:'JetBrains Mono',monospace; font-size:12px; transition:border-color 0.15s,box-shadow 0.15s; }button:hover { border-color:var(--accent); box-shadow:0 0 6px rgba(108,200,255,0.3); }button[data-live="true"] { border-color:rgba(255,227,77,0.6); color:#ffe34d; box-shadow:0 0 8px rgba(255,227,77,0.35); }input,select { background:#0d0d18; color:var(--text); border:1px solid var(--line); border-radius:5px; padding:4px 7px; }input:focus,select:focus { outline:none; border-color:var(--accent); box-shadow:0 0 5px rgba(108,200,255,0.25); }.field { display:flex; gap:8px; align-items:center; margin:5px 0; }.field label { width:130px; color:var(--muted); }.metrics { margin-top:14px; padding:10px; background:var(--panel); border-radius:8px; border:1px solid #1e1e38; }.metrics .m { display:flex; justify-content:space-between; }.metrics .m b { color:var(--accent); text-shadow:0 0 6px rgba(108,200,255,0.4); }.flag { color:#ffcf5a; }.live-label { color:#ffe34d; }table.matrix,table.bulk { border-collapse:collapse; font-size:12px; }table.matrix td,table.matrix th,table.bulk td,table.bulk th { border:1px solid var(--line); padding:3px 6px; text-align:center; }.cell { cursor:pointer; } .cell:hover { background:rgba(108,200,255,0.08); color:var(--accent); }- Step 3: Visual verification
cd tools/design-bible && python3 -m http.server 8080 &Open http://localhost:8080. Verify (live filter active by default):
-
Brand “◇ Design Bible” renders in Orbitron with cyan glow
-
“◈ Live” button shows gold border and gold text
-
Other buttons use JetBrains Mono, show cyan glow on hover
-
Active nav/list item has a visible cyan left border
-
Fire and Lightning list items show gold “◈ “ prefix
-
Metric values (bold) have a faint cyan glow
-
Reaction matrix cells show cyan tint on hover (not grey)
-
Kill server:
kill %1(orpkill -f "http.server 8080") -
Step 4: Run bible tests
cd tools/design-bible && node --test 2>&1 | tail -3Expected: 31 pass.
- Step 5: Commit
git add tools/design-bible/index.html tools/design-bible/css/style.cssgit commit -m "feat: bible neon restyle — Orbitron brand, JetBrains Mono body, gold live button, glow accents"