feat(board): canonical board definition and BoardView (T2)

Encode the board as blocks of stacked-section columns (C1) and render it as
the ASCII-styled DOM grid (C2) with hover highlight and a select emit. Layout
mirrors the physical board (Client Satisfaction above Product Architecture;
Macroplan above Defect Visualisation, both left of Feature Kanban) and boxes
use true ISO 216 proportions (1:√2, each step up scaled by √2). Both views
render from the one definition; tests pin section presence, click, hover,
layout order, and a snapshot.
This commit is contained in:
Julien Calixte
2026-05-27 22:23:54 +02:00
parent d8ff2c75c9
commit ae85d212db
7 changed files with 271 additions and 2 deletions

65
app/board/definition.ts Normal file
View File

@@ -0,0 +1,65 @@
// The canonical board (C1) — the single source both apps render from (F1).
// Section `id`s are STABLE: defects reference them, so renaming a label is safe
// but changing an id orphans existing defects. Add/rename here, then redeploy.
//
// Each block lays its sections out as COLUMNS of vertically-stacked sections,
// mirroring the physical board (e.g. Macroplan above Defect Visualisation, both
// to the left of Feature Kanban).
export type PaperSize = 'A2' | 'A3' | 'A4'
export interface Section {
id: string
label: string
size: PaperSize
}
export interface Block {
id: string
title: string
/** Left-to-right columns; each column is top-to-bottom sections. */
columns: Section[][]
}
export const board: Block[] = [
{
id: 'value-for-customer',
title: 'Value for Customer',
columns: [
[
{ id: 'client-satisfaction', label: 'Client Satisfaction', size: 'A4' },
{ id: 'product-architecture', label: 'Product Architecture', size: 'A4' },
],
],
},
{
id: 'right-first-time-jit',
title: 'Right First Time & Just in Time',
columns: [
[
{ id: 'macroplan', label: 'Macroplan', size: 'A4' },
{ id: 'defect-visualisation', label: 'Defect Visualisation', size: 'A4' },
],
[{ id: 'feature-kanban', label: 'Feature Kanban', size: 'A2' }],
],
},
{
id: 'tech-enabled-network',
title: 'Tech-Enabled Network of Teams',
columns: [[{ id: 'tech-working-condition', label: 'Tech Working Condition', size: 'A3' }]],
},
{
id: 'learning-organisation',
title: 'Building a Learning Organisation',
columns: [
[{ id: 'problem-solving', label: 'Problem Solving', size: 'A3' }],
[{ id: 'weak-point-management', label: 'Weak Point Management', size: 'A3' }],
],
},
]
/** Flat list of every section, for lookups and validation. */
export const sections: Section[] = board.flatMap((block) => block.columns.flat())
/** Set of valid section ids — used server-side to validate filed defects. */
export const sectionIds: ReadonlySet<string> = new Set(sections.map((s) => s.id))

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { ref } from 'vue'
import { board } from '~/board/definition'
defineEmits<{ select: [sectionId: string] }>()
// JS-tracked hover (rather than CSS :hover) so the highlight is observable in
// tests and can later coordinate with the dashboard dot layer.
const hoveredId = ref<string | null>(null)
</script>
<template>
<div class="board">
<section
v-for="block in board"
:key="block.id"
class="block"
:data-block-id="block.id"
>
<h2 class="block__title">{{ block.title }}</h2>
<div class="block__columns">
<div
v-for="(column, columnIndex) in block.columns"
:key="columnIndex"
class="block__column"
>
<button
v-for="section in column"
:key="section.id"
type="button"
class="section"
:class="[
`section--${section.size.toLowerCase()}`,
{ 'is-hovered': hoveredId === section.id },
]"
:data-section-id="section.id"
@click="$emit('select', section.id)"
@mouseenter="hoveredId = section.id"
@mouseleave="hoveredId = null"
>
<span class="section__label">{{ section.label }}</span>
<span class="section__size">{{ section.size }}</span>
</button>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.board {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.block {
border: 1px solid currentColor;
padding: 0.5rem;
}
.block__title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.block__columns {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.block__column {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px dashed currentColor;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
padding: 0.5rem;
box-sizing: border-box;
/* ISO 216: every A-series sheet is portrait 1 : √2. */
aspect-ratio: 1 / 1.41421356;
}
/* Each step up the A-series scales linear dimensions by √2 (A3 = A4·√2,
A2 = A4·2), so the boxes keep the true relative paper sizes. */
.section--a4 { width: 120px; }
.section--a3 { width: calc(120px * 1.41421356); }
.section--a2 { width: 240px; }
.section.is-hovered {
outline: 2px solid currentColor;
}
.section__size {
align-self: flex-end;
font-size: 0.75rem;
opacity: 0.7;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<main>
<h1>Andon weak point dashboard</h1>
<BoardView />
<p>The red-dot defect map lands in Task 7.</p>
</main>
</template>

View File

@@ -1,6 +1,13 @@
<script setup lang="ts">
const selected = ref<string | null>(null)
</script>
<template>
<main>
<h1>Andon report a board problem</h1>
<p>The interactive board lands in Task 2.</p>
<BoardView @select="(id) => (selected = id)" />
<p v-if="selected">
Selected section: <code>{{ selected }}</code> the defect form arrives in Task 5.
</p>
</main>
</template>