Files
blueprints/src/specimens/CalendarSpecimen.vue
Julien Calixte 9c8ba9a752 fix(a11y): raise faint ink text to meet WCAG AA contrast
Dim text tokens (base-content/40-50, #4f7099) sat at 3.0-4.1:1 on the
navy paper, below the 4.5:1 minimum. Brighten text uses to #7ea6cd
(~6:1) and split the overloaded --ink-faint into --ink-dim so borders,
stripes and diagram edges keep their intended faintness.
2026-07-02 23:05:53 +02:00

528 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from "vue"
const props = defineProps<{ activeId: string }>()
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// A month event bar, placed by (week row, start weekday, day span).
interface MonthEvent {
key: string
label: string
week: number
start: number // 0 = Mon … 6 = Sun
span: number // days
clip?: boolean // clipped at the viewport boundary
skeleton?: boolean
hl?: boolean
hlTag?: string
}
// A week-view event block, placed on the time axis.
interface WeekEvent {
key: string
label: string
day: number // 0 = Mon … 4 = Fri
hour: number // 0-based slot from the first row
hl?: boolean
}
// Shared header/chrome fields — both scopes have a navigable title + scope toggle.
interface BaseFrame {
title: string
scopeActive: "Day" | "Week" | "Month"
navHl?: boolean
scopeHl?: boolean
note?: string
}
interface MonthFrame extends BaseFrame {
scope: "month"
gridHl?: boolean
gridTag?: string
badge?: string
badgeNote?: string
events: MonthEvent[]
}
interface WeekFrame extends BaseFrame {
scope: "week"
hours: string[]
events: WeekEvent[]
}
type Frame = MonthFrame | WeekFrame
// April 2026, laid out as in the ontology README (Mon-first weeks).
// Leading 30 (Mar) and trailing 1-4 (May) are adjacent-month days.
const WEEKS: { day: number; adjacent?: boolean }[][] = [
[30, 1, 2, 3, 4, 5, 6].map((d, i) => ({ day: d, adjacent: i === 0 })),
[7, 8, 9, 10, 11, 12, 13].map((d) => ({ day: d })),
[14, 15, 16, 17, 18, 19, 20].map((d) => ({ day: d })),
[21, 22, 23, 24, 25, 26, 27].map((d) => ({ day: d })),
[28, 29, 30, 1, 2, 3, 4].map((d, i) => ({ day: d, adjacent: i >= 3 })),
]
// Events used across the month frames.
const meeting = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "meeting",
label: "Meeting",
week: 0,
start: 2,
span: 2,
...extra,
})
const sprint = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "sprint",
label: "Sprint review",
week: 1,
start: 0,
span: 1,
...extra,
})
const review = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "review",
label: "Design review",
week: 3,
start: 1,
span: 3,
...extra,
})
const frame = computed<Frame>(() => {
switch (props.activeId) {
case "full":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
navHl: true,
scopeHl: true,
gridHl: true,
gridTag: "◈ RENDER",
events: [meeting(), sprint(), review()],
}
case "navigate":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
navHl: true,
badge: "loadState = Loading",
badgeNote: "navigate(+1) → fetching May 2026…",
note: "navigate(+1) advances focusDate by one scope unit (a month here) → loadState ← Loading, viewport re-fetched.",
events: [meeting(), sprint(), review()],
}
case "render":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
gridHl: true,
gridTag: "◈ RENDER",
note: "visible = { e ∈ members | e.startAt ∈ [viewportStart, viewportEnd) } — the trailing event is clipped at viewportEnd; its data is not truncated.",
events: [
meeting({ hl: true, hlTag: "spans WedThu" }),
sprint(),
review(),
{ key: "overflow", label: "Offsite ▸", week: 4, start: 4, span: 3, clip: true },
],
}
case "load":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
badge: "loadState = Refreshing",
badgeNote: "re-fetching viewport range…",
note: "Skeleton bars mirror real event bars so the transition to Idle causes no layout shift. members = ∅ is a valid result, not an error.",
events: [
meeting({ skeleton: true }),
sprint({ skeleton: true }),
review({ skeleton: true }),
],
}
case "scope":
return {
scope: "week",
title: "Week of Apr 21",
scopeActive: "Week",
scopeHl: true,
note: "changeScope(Week) keeps focusDate (Apr 21) and re-fetches the new [viewportStart, viewportEnd) — layout switches to a time axis.",
hours: ["09:00", "10:00", "11:00", "12:00"],
events: [
{ key: "standup", label: "Stand-up", day: 0, hour: 0 },
{ key: "oneone", label: "1:1", day: 4, hour: 0 },
{ key: "design", label: "Design review", day: 2, hour: 2, hl: true },
],
}
default:
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
events: [meeting(), sprint(), review()],
}
}
})
const isMonth = computed(() => frame.value.scope === "month")
function barStyle(e: MonthEvent) {
return {
left: `calc(${(e.start / 7) * 100}% + 3px)`,
width: `calc(${(e.span / 7) * 100}% - 6px)`,
}
}
</script>
<template>
<div class="specimen">
<!-- header: scope toggle + navigation -->
<div class="cal-head">
<div
class="nav"
:class="frame.navHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.navHl ? ' NAVIGATE' : undefined"
>
<span class="arrow"></span>
<span class="title">{{ frame.title }}</span>
<span class="arrow"></span>
</div>
<div
class="scopes"
:class="frame.scopeHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.scopeHl ? ' SCOPE' : undefined"
>
<span
v-for="s in ['Day', 'Week', 'Month']"
:key="s"
class="scope-btn"
:class="{ on: frame.scopeActive === s }"
>{{ s }}</span
>
</div>
</div>
<div v-if="frame.scope === 'month' && frame.badge" class="spec-toolbar">
<span class="badge">{{ frame.badge }}</span>
<span v-if="frame.badgeNote" class="badge-note">{{ frame.badgeNote }}</span>
</div>
<p v-if="frame.note" class="note">{{ frame.note }}</p>
<!-- MONTH grid -->
<div
v-if="isMonth && frame.scope === 'month'"
class="monthbox"
:class="frame.gridHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.gridTag"
>
<div class="wd-row">
<div v-for="d in WEEKDAYS" :key="d" class="wd">{{ d }}</div>
</div>
<div v-for="(week, wi) in WEEKS" :key="wi" class="week">
<div class="daycells">
<div v-for="(c, ci) in week" :key="ci" class="daycell" :class="{ adjacent: c.adjacent }">
<span class="dnum">{{ c.day }}</span>
</div>
</div>
<div
v-for="e in frame.events.filter((x) => x.week === wi)"
:key="e.key"
class="ev"
:class="{ skeleton: e.skeleton, clip: e.clip, hl: e.hl }"
:style="barStyle(e)"
:data-tag="e.hlTag"
>
<span v-if="!e.skeleton" class="ev-label">{{ e.label }}</span>
</div>
</div>
</div>
<!-- WEEK time-axis grid -->
<div v-else-if="frame.scope === 'week'" class="weekbox">
<div class="wk-head">
<div class="wk-corner" />
<div v-for="d in WEEKDAYS.slice(0, 5)" :key="d" class="wk-day">{{ d }}</div>
</div>
<div v-for="(h, hi) in frame.hours" :key="h" class="wk-row">
<div class="wk-hour">{{ h }}</div>
<div v-for="di in 5" :key="di" class="wk-cell">
<div
v-for="e in frame.events.filter((x) => x.day === di - 1 && x.hour === hi)"
:key="e.key"
class="wk-ev"
:class="{ hl: e.hl }"
>
{{ e.label }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.specimen {
--ink: var(--color-base-content);
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
--amber: var(--color-accent);
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
--cyan: var(--color-secondary);
--red: var(--color-error);
--paper: var(--color-base-100);
--line: rgba(200, 226, 255, 0.14);
--line-strong: rgba(200, 226, 255, 0.3);
font-size: 13px;
}
.note {
color: var(--ink-dim);
font-size: 11px;
font-style: italic;
margin: 0 0 10px;
}
/* header */
.cal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.nav {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--line);
background: var(--paper);
padding: 5px 12px;
}
.nav .arrow {
color: var(--cyan);
font-size: 16px;
line-height: 1;
}
.nav .title {
color: var(--ink);
letter-spacing: 0.04em;
}
.scopes {
display: flex;
border: 1px solid var(--line);
}
.scope-btn {
padding: 4px 11px;
font-size: 11px;
color: var(--ink-dim);
border-left: 1px solid var(--line);
}
.scope-btn:first-child {
border-left: none;
}
.scope-btn.on {
color: var(--amber);
background: var(--amber-dim);
}
/* toolbar */
.spec-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.badge {
border: 1px solid var(--line-strong);
color: var(--cyan);
padding: 1px 7px;
font-size: 10px;
letter-spacing: 0.08em;
}
.badge-note {
color: var(--ink-dim);
}
/* month grid */
.monthbox {
border: 1px solid var(--line);
background: var(--paper);
}
.wd-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid var(--line);
}
.wd {
padding: 5px 6px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 55%, transparent);
text-align: right;
}
.week {
position: relative;
border-bottom: 1px solid var(--line);
}
.week:last-child {
border-bottom: none;
}
.daycells {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.daycell {
min-height: 46px;
border-left: 1px dashed var(--line);
padding: 4px 6px;
}
.daycell:first-child {
border-left: none;
}
.dnum {
font-size: 11px;
color: color-mix(in srgb, var(--ink) 70%, transparent);
}
.daycell.adjacent .dnum {
color: var(--ink-dim);
opacity: 0.55;
}
/* event bars, positioned over the day cells of their week */
.ev {
position: absolute;
top: 22px;
height: 18px;
display: flex;
align-items: center;
padding: 0 6px;
border: 1px solid var(--cyan);
background: color-mix(in srgb, var(--cyan) 16%, transparent);
font-size: 10px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
}
.ev-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ev.clip {
border-right-style: dashed;
border-color: var(--amber);
background: var(--amber-dim);
color: var(--amber);
-webkit-mask-image: linear-gradient(90deg, #000 78%, transparent);
mask-image: linear-gradient(90deg, #000 78%, transparent);
}
.ev.skeleton {
border-style: solid;
border-color: var(--line-strong);
background: rgba(255, 255, 255, 0.02);
}
.ev.skeleton::after {
content: "";
width: 62%;
height: 8px;
background: repeating-linear-gradient(
90deg,
var(--ink-faint),
var(--ink-faint) 6px,
transparent 6px,
transparent 12px
);
opacity: 0.5;
}
/* week / time-axis grid */
.weekbox {
border: 1px solid var(--line);
background: var(--paper);
}
.wk-head,
.wk-row {
display: grid;
grid-template-columns: 48px repeat(5, 1fr);
}
.wk-head {
border-bottom: 1px solid var(--line);
}
.wk-corner {
border-right: 1px solid var(--line);
}
.wk-day {
padding: 5px 6px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 55%, transparent);
text-align: center;
}
.wk-row {
border-bottom: 1px solid var(--line);
}
.wk-row:last-child {
border-bottom: none;
}
.wk-hour {
padding: 6px;
font-size: 10px;
color: var(--ink-dim);
border-right: 1px solid var(--line);
text-align: right;
}
.wk-cell {
min-height: 30px;
border-left: 1px dashed var(--line);
padding: 3px;
}
.wk-cell:first-of-type {
border-left: none;
}
.wk-ev {
border: 1px solid var(--cyan);
background: color-mix(in srgb, var(--cyan) 16%, transparent);
color: var(--ink);
font-size: 10px;
padding: 2px 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* callouts */
.hl {
outline: 1.5px dashed var(--amber);
outline-offset: 3px;
position: relative;
z-index: 2;
}
.hl[data-tag]::after {
content: attr(data-tag);
position: absolute;
top: -10px;
left: 8px;
background: var(--paper);
color: var(--amber);
font-size: 9px;
padding: 0 5px;
border: 1px solid var(--amber);
white-space: nowrap;
letter-spacing: 0.1em;
line-height: 1.5;
z-index: 3;
}
.hl.tag-b[data-tag]::after {
top: auto;
bottom: -10px;
}
.ev.hl {
z-index: 4;
}
.wk-ev.hl {
outline-color: var(--amber);
border-color: var(--amber);
background: var(--amber-dim);
color: var(--amber);
}
</style>