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:
65
app/board/definition.ts
Normal file
65
app/board/definition.ts
Normal 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))
|
||||
112
app/components/BoardView.vue
Normal file
112
app/components/BoardView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@ review.
|
||||
|
||||
## Phase 1 — Foundation & board spine
|
||||
- [x] **T1** Scaffold Nuxt 4 + tooling + Dockerised dev (M) — _deps: none_
|
||||
- [ ] **T2** Board-definition module (C1) + BoardView (C2) — F1 (M) — _deps: T1_
|
||||
- [x] **T2** Board-definition module (C1) + BoardView (C2) — F1 (M) — _deps: T1_
|
||||
- [ ] ⛳ **Checkpoint:** build + tests pass, board interactive, human review
|
||||
|
||||
## Phase 2 — File a defect (G1)
|
||||
|
||||
32
tests/__snapshots__/board.spec.ts.snap
Normal file
32
tests/__snapshots__/board.spec.ts.snap
Normal file
@@ -0,0 +1,32 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`BoardView > matches the rendered board snapshot 1`] = `
|
||||
"<div data-v-0ced9e89="" class="board">
|
||||
<section data-v-0ced9e89="" class="block" data-block-id="value-for-customer">
|
||||
<h2 data-v-0ced9e89="" class="block__title">Value for Customer</h2>
|
||||
<div data-v-0ced9e89="" class="block__columns">
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a4" data-section-id="client-satisfaction"><span data-v-0ced9e89="" class="section__label">Client Satisfaction</span><span data-v-0ced9e89="" class="section__size">A4</span></button><button data-v-0ced9e89="" type="button" class="section section--a4" data-section-id="product-architecture"><span data-v-0ced9e89="" class="section__label">Product Architecture</span><span data-v-0ced9e89="" class="section__size">A4</span></button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-v-0ced9e89="" class="block" data-block-id="right-first-time-jit">
|
||||
<h2 data-v-0ced9e89="" class="block__title">Right First Time & Just in Time</h2>
|
||||
<div data-v-0ced9e89="" class="block__columns">
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a4" data-section-id="macroplan"><span data-v-0ced9e89="" class="section__label">Macroplan</span><span data-v-0ced9e89="" class="section__size">A4</span></button><button data-v-0ced9e89="" type="button" class="section section--a4" data-section-id="defect-visualisation"><span data-v-0ced9e89="" class="section__label">Defect Visualisation</span><span data-v-0ced9e89="" class="section__size">A4</span></button></div>
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a2" data-section-id="feature-kanban"><span data-v-0ced9e89="" class="section__label">Feature Kanban</span><span data-v-0ced9e89="" class="section__size">A2</span></button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-v-0ced9e89="" class="block" data-block-id="tech-enabled-network">
|
||||
<h2 data-v-0ced9e89="" class="block__title">Tech-Enabled Network of Teams</h2>
|
||||
<div data-v-0ced9e89="" class="block__columns">
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a3" data-section-id="tech-working-condition"><span data-v-0ced9e89="" class="section__label">Tech Working Condition</span><span data-v-0ced9e89="" class="section__size">A3</span></button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section data-v-0ced9e89="" class="block" data-block-id="learning-organisation">
|
||||
<h2 data-v-0ced9e89="" class="block__title">Building a Learning Organisation</h2>
|
||||
<div data-v-0ced9e89="" class="block__columns">
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a3" data-section-id="problem-solving"><span data-v-0ced9e89="" class="section__label">Problem Solving</span><span data-v-0ced9e89="" class="section__size">A3</span></button></div>
|
||||
<div data-v-0ced9e89="" class="block__column"><button data-v-0ced9e89="" type="button" class="section section--a3" data-section-id="weak-point-management"><span data-v-0ced9e89="" class="section__label">Weak Point Management</span><span data-v-0ced9e89="" class="section__size">A3</span></button></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>"
|
||||
`;
|
||||
52
tests/board.spec.ts
Normal file
52
tests/board.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment nuxt
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import BoardView from '../app/components/BoardView.vue'
|
||||
import { board, sections } from '../app/board/definition'
|
||||
|
||||
describe('BoardView', () => {
|
||||
it('renders every block and section from the one definition', async () => {
|
||||
const wrapper = await mountSuspended(BoardView)
|
||||
for (const block of board) {
|
||||
expect(wrapper.text()).toContain(block.title)
|
||||
}
|
||||
for (const section of sections) {
|
||||
expect(wrapper.find(`[data-section-id="${section.id}"]`).exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain(section.label)
|
||||
}
|
||||
})
|
||||
|
||||
it('emits the stable section id on click', async () => {
|
||||
const wrapper = await mountSuspended(BoardView)
|
||||
await wrapper.find('[data-section-id="macroplan"]').trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual(['macroplan'])
|
||||
})
|
||||
|
||||
it('highlights a section on hover', async () => {
|
||||
const wrapper = await mountSuspended(BoardView)
|
||||
const macroplan = wrapper.find('[data-section-id="macroplan"]')
|
||||
expect(macroplan.classes()).not.toContain('is-hovered')
|
||||
await macroplan.trigger('mouseenter')
|
||||
expect(macroplan.classes()).toContain('is-hovered')
|
||||
await macroplan.trigger('mouseleave')
|
||||
expect(macroplan.classes()).not.toContain('is-hovered')
|
||||
})
|
||||
|
||||
it('stacks and orders sections as on the physical board', async () => {
|
||||
const wrapper = await mountSuspended(BoardView)
|
||||
const order = wrapper
|
||||
.findAll('[data-section-id]')
|
||||
.map((el) => el.attributes('data-section-id'))
|
||||
const at = (id: string) => order.indexOf(id)
|
||||
// Product Architecture below Client Satisfaction (same column).
|
||||
expect(at('client-satisfaction')).toBeLessThan(at('product-architecture'))
|
||||
// Macroplan above Defect Visualisation, both left of Feature Kanban.
|
||||
expect(at('macroplan')).toBeLessThan(at('defect-visualisation'))
|
||||
expect(at('defect-visualisation')).toBeLessThan(at('feature-kanban'))
|
||||
})
|
||||
|
||||
it('matches the rendered board snapshot', async () => {
|
||||
const wrapper = await mountSuspended(BoardView)
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user