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.
528 lines
13 KiB
Vue
528 lines
13 KiB
Vue
<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 Wed–Thu" }),
|
||
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>
|