diff --git a/150b3a-virtual-piano/utils/audio-helpers.gts b/150b3a-virtual-piano/utils/audio-helpers.gts new file mode 100644 index 00000000..66b8c430 --- /dev/null +++ b/150b3a-virtual-piano/utils/audio-helpers.gts @@ -0,0 +1,170 @@ +/* ── Frequency calculation ───────────────────────────────────────────── */ +export const BASE_FREQS: Record = { + C: 261.63, + 'C#': 277.18, + D: 293.66, + 'D#': 311.13, + E: 329.63, + F: 349.23, + 'F#': 369.99, + G: 392.0, + 'G#': 415.3, + A: 440.0, + 'A#': 466.16, + B: 493.88, +}; + +export function noteFreq(note: string, octave: number): number { + return (BASE_FREQS[note] ?? 440) * Math.pow(2, octave - 4); +} + +/* ── Instrument profiles (harmonic stacking + ADSR) ─────────────────── */ +export interface InstrumentProfile { + harmonics: { ratio: number; gain: number; detune?: number }[]; + attack: number; + decay: number; + sustainLevel: number; + releaseDecay: number; /* how fast the tail fades (seconds to near-zero) */ +} + +export const INSTRUMENT_PROFILES: Record = { + classical: { + /* Grand piano: inharmonic overtones (real strings are slightly sharp above + fundamental), two detuned unison oscillators for natural "chorus". + ADSR: very fast attack → rapid initial decay → slow long tail (piano + strings don't have a flat sustain level — they just keep decaying). */ + harmonics: [ + { ratio: 1.0, gain: 0.5 } /* fundamental */, + { ratio: 1.0, gain: 0.1, detune: 5 } /* unison +5 cents */, + { ratio: 2.005, gain: 0.22 } /* 2nd harmonic slightly sharp */, + { ratio: 3.015, gain: 0.1 } /* 3rd */, + { ratio: 4.03, gain: 0.06 } /* 4th */, + { ratio: 5.05, gain: 0.03 } /* 5th */, + { ratio: 6.08, gain: 0.015 } /* 6th */, + { ratio: 8.13, gain: 0.008 } /* 8th */, + ], + attack: 0.004, + decay: 0.12, + sustainLevel: 0.18 /* piano strings continue decaying — no flat sustain */, + releaseDecay: 1.8, + }, + electric: { + /* Rhodes-style: mellow mid harmonics, slightly warmer detune */ + harmonics: [ + { ratio: 1.0, gain: 0.42 }, + { ratio: 1.0, gain: 0.08, detune: 3 }, + { ratio: 2.002, gain: 0.28 }, + { ratio: 3.005, gain: 0.15 }, + { ratio: 4.01, gain: 0.08 }, + { ratio: 5.02, gain: 0.04 }, + ], + attack: 0.012, + decay: 0.2, + sustainLevel: 0.25, + releaseDecay: 1.2, + }, + organ: { + /* Hammond-style: perfectly harmonic, steady sustain — NO decay */ + harmonics: [ + { ratio: 1.0, gain: 0.38 }, + { ratio: 2.0, gain: 0.28 }, + { ratio: 3.0, gain: 0.2 }, + { ratio: 4.0, gain: 0.1 }, + { ratio: 6.0, gain: 0.04 }, + ], + attack: 0.006, + decay: 0.01, + sustainLevel: 0.45, + releaseDecay: 0.06, + }, + harpsichord: { + /* Sharp percussive attack, fast decay, bright upper harmonics */ + harmonics: [ + { ratio: 1.0, gain: 0.48 }, + { ratio: 2.002, gain: 0.26 }, + { ratio: 4.008, gain: 0.16 }, + { ratio: 8.02, gain: 0.08 }, + { ratio: 16.05, gain: 0.03 }, + ], + attack: 0.003, + decay: 0.03, + sustainLevel: 0.06, + releaseDecay: 0.5, + }, + felt: { + /* Felt/soft piano: muted, warm — piano with felt strip on strings */ + harmonics: [ + { ratio: 1.0, gain: 0.48 }, + { ratio: 2.002, gain: 0.14 }, + { ratio: 3.01, gain: 0.06 }, + { ratio: 4.02, gain: 0.03 }, + ], + attack: 0.008, + decay: 0.18, + sustainLevel: 0.22, + releaseDecay: 2.4, + }, + bright: { + /* Bright piano: strong upper harmonics, crisp attack */ + harmonics: [ + { ratio: 1.0, gain: 0.42 }, + { ratio: 1.0, gain: 0.09, detune: 7 }, + { ratio: 2.005, gain: 0.26 }, + { ratio: 3.02, gain: 0.16 }, + { ratio: 4.04, gain: 0.12 }, + { ratio: 5.08, gain: 0.08 }, + { ratio: 6.12, gain: 0.05 }, + { ratio: 8.2, gain: 0.03 }, + ], + attack: 0.002, + decay: 0.08, + sustainLevel: 0.14, + releaseDecay: 1.5, + }, + symphonic: { + /* Symphonic / Concert Grand: rich overtones, long tail */ + harmonics: [ + { ratio: 1.0, gain: 0.45 }, + { ratio: 1.0, gain: 0.12, detune: 4 }, + { ratio: 2.004, gain: 0.24 }, + { ratio: 3.012, gain: 0.14 }, + { ratio: 4.025, gain: 0.09 }, + { ratio: 5.045, gain: 0.05 }, + { ratio: 6.07, gain: 0.03 }, + { ratio: 7.1, gain: 0.02 }, + ], + attack: 0.005, + decay: 0.15, + sustainLevel: 0.2, + releaseDecay: 3.2, + }, + violin: { + /* Violin: slow bow attack, flat sustain, expressive tail */ + harmonics: [ + { ratio: 1.0, gain: 0.4 }, + { ratio: 2.0, gain: 0.3 }, + { ratio: 3.0, gain: 0.18 }, + { ratio: 4.0, gain: 0.08 }, + { ratio: 5.0, gain: 0.04 }, + ], + attack: 0.08, + decay: 0.05, + sustainLevel: 0.42, + releaseDecay: 0.3, + }, + harp: { + /* Harp: plucked, clean fast attack, warm decay */ + harmonics: [ + { ratio: 1.0, gain: 0.5 }, + { ratio: 2.001, gain: 0.22 }, + { ratio: 3.004, gain: 0.12 }, + { ratio: 4.009, gain: 0.07 }, + { ratio: 5.016, gain: 0.04 }, + { ratio: 6.025, gain: 0.02 }, + ], + attack: 0.003, + decay: 0.05, + sustainLevel: 0.1, + releaseDecay: 2.8, + }, +}; diff --git a/150b3a-virtual-piano/utils/keyboard-helpers.gts b/150b3a-virtual-piano/utils/keyboard-helpers.gts new file mode 100644 index 00000000..1e95ff24 --- /dev/null +++ b/150b3a-virtual-piano/utils/keyboard-helpers.gts @@ -0,0 +1,187 @@ +/* ═══════════════════════════════════════════════════════════════════════════ + VP.NET KEYBOARD MAPPING — 61 keys C2–C7 + ───────────────────────────────────────────────────────────────────────── + White keys per octave: + Oct 2: 1 2 3 4 5 6 7 + Oct 3: 8 9 0 q w e r + Oct 4: t y u i o p a ← Middle C (t = C4) + Oct 5: s d f g h j k + Oct 6: l z x c v b n + C7 only: m + + Black keys (Shift+key): + Oct 2: ! @ $ % ^ + Oct 3: * ( Q W E + Oct 4: T Y I O P + Oct 5: S D G H J + Oct 6: L Z C V B + ═══════════════════════════════════════════════════════════════════════════ */ +export const KEYBOARD_MAPPING: Record< + string, + { note: string; octave: number } +> = { + /* oct 2 */ '1': { note: 'C', octave: 2 }, + '!': { note: 'C#', octave: 2 }, + '2': { note: 'D', octave: 2 }, + '@': { note: 'D#', octave: 2 }, + '3': { note: 'E', octave: 2 }, + '4': { note: 'F', octave: 2 }, + $: { note: 'F#', octave: 2 }, + '5': { note: 'G', octave: 2 }, + '%': { note: 'G#', octave: 2 }, + '6': { note: 'A', octave: 2 }, + '^': { note: 'A#', octave: 2 }, + '7': { note: 'B', octave: 2 }, + /* oct 3 */ '8': { note: 'C', octave: 3 }, + '*': { note: 'C#', octave: 3 }, + '9': { note: 'D', octave: 3 }, + '(': { note: 'D#', octave: 3 }, + '0': { note: 'E', octave: 3 }, + q: { note: 'F', octave: 3 }, + Q: { note: 'F#', octave: 3 }, + w: { note: 'G', octave: 3 }, + W: { note: 'G#', octave: 3 }, + e: { note: 'A', octave: 3 }, + E: { note: 'A#', octave: 3 }, + r: { note: 'B', octave: 3 }, + /* oct 4 */ t: { note: 'C', octave: 4 }, + T: { note: 'C#', octave: 4 }, + y: { note: 'D', octave: 4 }, + Y: { note: 'D#', octave: 4 }, + u: { note: 'E', octave: 4 }, + i: { note: 'F', octave: 4 }, + I: { note: 'F#', octave: 4 }, + o: { note: 'G', octave: 4 }, + O: { note: 'G#', octave: 4 }, + p: { note: 'A', octave: 4 }, + P: { note: 'A#', octave: 4 }, + a: { note: 'B', octave: 4 }, + /* oct 5 */ s: { note: 'C', octave: 5 }, + S: { note: 'C#', octave: 5 }, + d: { note: 'D', octave: 5 }, + D: { note: 'D#', octave: 5 }, + f: { note: 'E', octave: 5 }, + g: { note: 'F', octave: 5 }, + G: { note: 'F#', octave: 5 }, + h: { note: 'G', octave: 5 }, + H: { note: 'G#', octave: 5 }, + j: { note: 'A', octave: 5 }, + J: { note: 'A#', octave: 5 }, + k: { note: 'B', octave: 5 }, + /* oct 6 */ l: { note: 'C', octave: 6 }, + L: { note: 'C#', octave: 6 }, + z: { note: 'D', octave: 6 }, + Z: { note: 'D#', octave: 6 }, + x: { note: 'E', octave: 6 }, + c: { note: 'F', octave: 6 }, + C: { note: 'F#', octave: 6 }, + v: { note: 'G', octave: 6 }, + V: { note: 'G#', octave: 6 }, + b: { note: 'A', octave: 6 }, + B: { note: 'A#', octave: 6 }, + n: { note: 'B', octave: 6 }, + /* C7 */ m: { note: 'C', octave: 7 }, +}; + +export const SHIFT_KEY_MAPPING: Record = { + Digit1: '!', + Digit2: '@', + Digit4: '$', + Digit5: '%', + Digit6: '^', + Digit8: '*', + Digit9: '(', + KeyQ: 'Q', + KeyW: 'W', + KeyE: 'E', + KeyT: 'T', + KeyY: 'Y', + KeyI: 'I', + KeyO: 'O', + KeyP: 'P', + KeyS: 'S', + KeyD: 'D', + KeyG: 'G', + KeyH: 'H', + KeyJ: 'J', + KeyL: 'L', + KeyZ: 'Z', + KeyC: 'C', + KeyV: 'V', + KeyB: 'B', +}; + +export function pianoKeyFromKeyboardEvent(e: KeyboardEvent): string { + if (e.shiftKey) { + return SHIFT_KEY_MAPPING[e.code] ?? e.key; + } + return e.key; +} + +/* reverse map: "C4" → keyboard letter */ +export const NOTE_TO_KEY: Record = {}; +for (const [k, v] of Object.entries(KEYBOARD_MAPPING)) { + NOTE_TO_KEY[`${v.note}${v.octave}`] = k; +} + +/* ── Piano key layout data ───────────────────────────────────────────── */ +export interface KeyData { + note: string; + octave: number; + isBlack: boolean; + id: string; + kbKey: string; + leftPx?: number; /* absolute left offset (px) for black keys only */ +} + +export function buildKeyLayout(): KeyData[] { + const WHITE_NOTES = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; + const BLACK_AFTER: Record = { + C: 'C#', + D: 'D#', + F: 'F#', + G: 'G#', + A: 'A#', + }; + /* WW = white-key slot width: key (38 px) + flex gap (2 px) = 40 px */ + const WW = 40; + /* BW = black key visual width */ + const BW = 26; + const keys: KeyData[] = []; + let wIdx = 0; /* running white-key counter for leftPx */ + for (const oct of [2, 3, 4, 5, 6]) { + for (const note of WHITE_NOTES) { + const id = `${note}${oct}`; + keys.push({ + note, + octave: oct, + isBlack: false, + id, + kbKey: NOTE_TO_KEY[id] ?? '', + }); + if (BLACK_AFTER[note]) { + const bNote = BLACK_AFTER[note]!; + const bid = `${bNote}${oct}`; + /* Centre black key over the boundary between this white key and the next: + right edge of wIdx key = (wIdx+1)*WW (gap is included), + minus half black-key width = centre over that boundary. */ + const leftPx = (wIdx + 1) * WW - Math.round(BW / 2); + keys.push({ + note: bNote, + octave: oct, + isBlack: true, + id: bid, + kbKey: NOTE_TO_KEY[bid] ?? '', + leftPx, + }); + } + wIdx++; + } + } + keys.push({ note: 'C', octave: 7, isBlack: false, id: 'C7', kbKey: 'm' }); + return keys; +} + +export const KEY_LAYOUT = buildKeyLayout(); +export const WHITE_KEYS = KEY_LAYOUT.filter((k) => !k.isBlack); +export const BLACK_KEYS = KEY_LAYOUT.filter((k) => k.isBlack); diff --git a/150b3a-virtual-piano/utils/notation-helpers.gts b/150b3a-virtual-piano/utils/notation-helpers.gts new file mode 100644 index 00000000..45e2ab79 --- /dev/null +++ b/150b3a-virtual-piano/utils/notation-helpers.gts @@ -0,0 +1,58 @@ +import { KEYBOARD_MAPPING } from './keyboard-helpers'; + +/* ═══════════════════════════════════════════════════════════════════════════ + BEAT PARSER + VP.net notation rules: + • Whitespace separates groups (phrase chunks) + • Within a group, each CHARACTER = one beat played in sequence + • [abc] inside a group = one chord beat (all keys simultaneously) + • - = rest beat (silence) + • | = phrase pause / timing separator + ───────────────────────────────────────────────────────────────────────── + Example: "pf[80wp]" → beat(p), beat(f), chord-beat(8,0,w,p) + ═══════════════════════════════════════════════════════════════════════════ */ +export interface Beat { + keys: string[]; /* keys to press; empty = rest */ + isChord: boolean; /* true when multiple keys from [...] */ + isPause: boolean; /* rest or phrase divider */ + display: string; /* what to render in the sheet */ +} + +export function parseNotationBeats(notation: string): Beat[] { + const beats: Beat[] = []; + const chunks = notation.split(/\s+/).filter((t) => t.length > 0); + for (const chunk of chunks) { + let i = 0; + while (i < chunk.length) { + if (chunk[i] === '[') { + const end = chunk.indexOf(']', i); + if (end === -1) { + i++; + continue; + } + const inner = chunk.slice(i + 1, end); + const keys = inner.split('').filter((k) => k in KEYBOARD_MAPPING); + beats.push({ + keys, + isChord: true, + isPause: false, + display: `[${inner}]`, + }); + i = end + 1; + } else if (chunk[i] === '-') { + beats.push({ keys: [], isChord: false, isPause: true, display: '—' }); + i++; + } else if (chunk[i] === '|') { + beats.push({ keys: [], isChord: false, isPause: true, display: '|' }); + i++; + } else { + const k = chunk[i]!; + if (k in KEYBOARD_MAPPING) { + beats.push({ keys: [k], isChord: false, isPause: false, display: k }); + } + i++; + } + } + } + return beats; +} diff --git a/150b3a-virtual-piano/virtual-piano.gts b/150b3a-virtual-piano/virtual-piano.gts index f6a9b877..e8b212a8 100644 --- a/150b3a-virtual-piano/virtual-piano.gts +++ b/150b3a-virtual-piano/virtual-piano.gts @@ -33,134 +33,20 @@ import { import PianoIcon from '@cardstack/boxel-icons/piano'; import type { Genre } from './genre'; import { diffLabel, diffClass } from './utils/diff-helpers'; +import { + KEYBOARD_MAPPING, + pianoKeyFromKeyboardEvent, + type KeyData, + WHITE_KEYS, + BLACK_KEYS, +} from './utils/keyboard-helpers'; +import { type Beat, parseNotationBeats } from './utils/notation-helpers'; +import { noteFreq, INSTRUMENT_PROFILES } from './utils/audio-helpers'; /* @ts-expect-error import.meta is valid ESM */ const here: string = import.meta.url; const musicSheetRef = codeRef(here, './music-sheet', 'MusicSheet'); -/* ═══════════════════════════════════════════════════════════════════════════ - VP.NET KEYBOARD MAPPING — 61 keys C2–C7 - ───────────────────────────────────────────────────────────────────────── - White keys per octave: - Oct 2: 1 2 3 4 5 6 7 - Oct 3: 8 9 0 q w e r - Oct 4: t y u i o p a ← Middle C (t = C4) - Oct 5: s d f g h j k - Oct 6: l z x c v b n - C7 only: m - - Black keys (Shift+key): - Oct 2: ! @ $ % ^ - Oct 3: * ( Q W E - Oct 4: T Y I O P - Oct 5: S D G H J - Oct 6: L Z C V B - ═══════════════════════════════════════════════════════════════════════════ */ -const KEYBOARD_MAPPING: Record = { - /* oct 2 */ '1': { note: 'C', octave: 2 }, - '!': { note: 'C#', octave: 2 }, - '2': { note: 'D', octave: 2 }, - '@': { note: 'D#', octave: 2 }, - '3': { note: 'E', octave: 2 }, - '4': { note: 'F', octave: 2 }, - $: { note: 'F#', octave: 2 }, - '5': { note: 'G', octave: 2 }, - '%': { note: 'G#', octave: 2 }, - '6': { note: 'A', octave: 2 }, - '^': { note: 'A#', octave: 2 }, - '7': { note: 'B', octave: 2 }, - /* oct 3 */ '8': { note: 'C', octave: 3 }, - '*': { note: 'C#', octave: 3 }, - '9': { note: 'D', octave: 3 }, - '(': { note: 'D#', octave: 3 }, - '0': { note: 'E', octave: 3 }, - q: { note: 'F', octave: 3 }, - Q: { note: 'F#', octave: 3 }, - w: { note: 'G', octave: 3 }, - W: { note: 'G#', octave: 3 }, - e: { note: 'A', octave: 3 }, - E: { note: 'A#', octave: 3 }, - r: { note: 'B', octave: 3 }, - /* oct 4 */ t: { note: 'C', octave: 4 }, - T: { note: 'C#', octave: 4 }, - y: { note: 'D', octave: 4 }, - Y: { note: 'D#', octave: 4 }, - u: { note: 'E', octave: 4 }, - i: { note: 'F', octave: 4 }, - I: { note: 'F#', octave: 4 }, - o: { note: 'G', octave: 4 }, - O: { note: 'G#', octave: 4 }, - p: { note: 'A', octave: 4 }, - P: { note: 'A#', octave: 4 }, - a: { note: 'B', octave: 4 }, - /* oct 5 */ s: { note: 'C', octave: 5 }, - S: { note: 'C#', octave: 5 }, - d: { note: 'D', octave: 5 }, - D: { note: 'D#', octave: 5 }, - f: { note: 'E', octave: 5 }, - g: { note: 'F', octave: 5 }, - G: { note: 'F#', octave: 5 }, - h: { note: 'G', octave: 5 }, - H: { note: 'G#', octave: 5 }, - j: { note: 'A', octave: 5 }, - J: { note: 'A#', octave: 5 }, - k: { note: 'B', octave: 5 }, - /* oct 6 */ l: { note: 'C', octave: 6 }, - L: { note: 'C#', octave: 6 }, - z: { note: 'D', octave: 6 }, - Z: { note: 'D#', octave: 6 }, - x: { note: 'E', octave: 6 }, - c: { note: 'F', octave: 6 }, - C: { note: 'F#', octave: 6 }, - v: { note: 'G', octave: 6 }, - V: { note: 'G#', octave: 6 }, - b: { note: 'A', octave: 6 }, - B: { note: 'A#', octave: 6 }, - n: { note: 'B', octave: 6 }, - /* C7 */ m: { note: 'C', octave: 7 }, -}; - -const SHIFT_KEY_MAPPING: Record = { - Digit1: '!', - Digit2: '@', - Digit4: '$', - Digit5: '%', - Digit6: '^', - Digit8: '*', - Digit9: '(', - KeyQ: 'Q', - KeyW: 'W', - KeyE: 'E', - KeyT: 'T', - KeyY: 'Y', - KeyI: 'I', - KeyO: 'O', - KeyP: 'P', - KeyS: 'S', - KeyD: 'D', - KeyG: 'G', - KeyH: 'H', - KeyJ: 'J', - KeyL: 'L', - KeyZ: 'Z', - KeyC: 'C', - KeyV: 'V', - KeyB: 'B', -}; - -function pianoKeyFromKeyboardEvent(e: KeyboardEvent): string { - if (e.shiftKey) { - return SHIFT_KEY_MAPPING[e.code] ?? e.key; - } - return e.key; -} - -/* reverse map: "C4" → keyboard letter */ -const NOTE_TO_KEY: Record = {}; -for (const [k, v] of Object.entries(KEYBOARD_MAPPING)) { - NOTE_TO_KEY[`${v.note}${v.octave}`] = k; -} - /* ── Song data shape ─────────────────────────────────────────────────── */ interface SongData { title: string; @@ -173,127 +59,6 @@ interface SongData { timeSignature: string; } -/* ── Difficulty helpers (mirrors piano-song.gts) ─────────────────────── */ - -/* ── Piano key layout data ───────────────────────────────────────────── */ -interface KeyData { - note: string; - octave: number; - isBlack: boolean; - id: string; - kbKey: string; - leftPx?: number; /* absolute left offset (px) for black keys only */ -} - -function buildKeyLayout(): KeyData[] { - const WHITE_NOTES = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; - const BLACK_AFTER: Record = { - C: 'C#', - D: 'D#', - F: 'F#', - G: 'G#', - A: 'A#', - }; - /* WW = white-key slot width: key (38 px) + flex gap (2 px) = 40 px */ - const WW = 40; - /* BW = black key visual width */ - const BW = 26; - const keys: KeyData[] = []; - let wIdx = 0; /* running white-key counter for leftPx */ - for (const oct of [2, 3, 4, 5, 6]) { - for (const note of WHITE_NOTES) { - const id = `${note}${oct}`; - keys.push({ - note, - octave: oct, - isBlack: false, - id, - kbKey: NOTE_TO_KEY[id] ?? '', - }); - if (BLACK_AFTER[note]) { - const bNote = BLACK_AFTER[note]!; - const bid = `${bNote}${oct}`; - /* Centre black key over the boundary between this white key and the next: - right edge of wIdx key = (wIdx+1)*WW (gap is included), - minus half black-key width = centre over that boundary. */ - const leftPx = (wIdx + 1) * WW - Math.round(BW / 2); - keys.push({ - note: bNote, - octave: oct, - isBlack: true, - id: bid, - kbKey: NOTE_TO_KEY[bid] ?? '', - leftPx, - }); - } - wIdx++; - } - } - keys.push({ note: 'C', octave: 7, isBlack: false, id: 'C7', kbKey: 'm' }); - return keys; -} - -const KEY_LAYOUT = buildKeyLayout(); -const WHITE_KEYS = KEY_LAYOUT.filter((k) => !k.isBlack); -const BLACK_KEYS = KEY_LAYOUT.filter((k) => k.isBlack); - -/* ═══════════════════════════════════════════════════════════════════════════ - BEAT PARSER - VP.net notation rules: - • Whitespace separates groups (phrase chunks) - • Within a group, each CHARACTER = one beat played in sequence - • [abc] inside a group = one chord beat (all keys simultaneously) - • - = rest beat (silence) - • | = phrase pause / timing separator - ───────────────────────────────────────────────────────────────────────── - Example: "pf[80wp]" → beat(p), beat(f), chord-beat(8,0,w,p) - ═══════════════════════════════════════════════════════════════════════════ */ -interface Beat { - keys: string[]; /* keys to press; empty = rest */ - isChord: boolean; /* true when multiple keys from [...] */ - isPause: boolean; /* rest or phrase divider */ - display: string; /* what to render in the sheet */ -} - -function parseNotationBeats(notation: string): Beat[] { - const beats: Beat[] = []; - const chunks = notation.split(/\s+/).filter((t) => t.length > 0); - for (const chunk of chunks) { - let i = 0; - while (i < chunk.length) { - if (chunk[i] === '[') { - const end = chunk.indexOf(']', i); - if (end === -1) { - i++; - continue; - } - const inner = chunk.slice(i + 1, end); - const keys = inner.split('').filter((k) => k in KEYBOARD_MAPPING); - beats.push({ - keys, - isChord: true, - isPause: false, - display: `[${inner}]`, - }); - i = end + 1; - } else if (chunk[i] === '-') { - beats.push({ keys: [], isChord: false, isPause: true, display: '—' }); - i++; - } else if (chunk[i] === '|') { - beats.push({ keys: [], isChord: false, isPause: true, display: '|' }); - i++; - } else { - const k = chunk[i]!; - if (k in KEYBOARD_MAPPING) { - beats.push({ keys: [k], isChord: false, isPause: false, display: k }); - } - i++; - } - } - } - return beats; -} - /* ── Global keyboard modifier ────────────────────────────────────────── */ const keyboardModifier = modifier( ( @@ -345,177 +110,6 @@ const sheetAutoScrollModifier = modifier( }, ); -/* ── Frequency calculation ───────────────────────────────────────────── */ -const BASE_FREQS: Record = { - C: 261.63, - 'C#': 277.18, - D: 293.66, - 'D#': 311.13, - E: 329.63, - F: 349.23, - 'F#': 369.99, - G: 392.0, - 'G#': 415.3, - A: 440.0, - 'A#': 466.16, - B: 493.88, -}; - -function noteFreq(note: string, octave: number): number { - return (BASE_FREQS[note] ?? 440) * Math.pow(2, octave - 4); -} - -/* ── Instrument profiles (harmonic stacking + ADSR) ─────────────────── */ -interface InstrumentProfile { - harmonics: { ratio: number; gain: number; detune?: number }[]; - attack: number; - decay: number; - sustainLevel: number; - releaseDecay: number; /* how fast the tail fades (seconds to near-zero) */ -} - -const INSTRUMENT_PROFILES: Record = { - classical: { - /* Grand piano: inharmonic overtones (real strings are slightly sharp above - fundamental), two detuned unison oscillators for natural "chorus". - ADSR: very fast attack → rapid initial decay → slow long tail (piano - strings don't have a flat sustain level — they just keep decaying). */ - harmonics: [ - { ratio: 1.0, gain: 0.5 } /* fundamental */, - { ratio: 1.0, gain: 0.1, detune: 5 } /* unison +5 cents */, - { ratio: 2.005, gain: 0.22 } /* 2nd harmonic slightly sharp */, - { ratio: 3.015, gain: 0.1 } /* 3rd */, - { ratio: 4.03, gain: 0.06 } /* 4th */, - { ratio: 5.05, gain: 0.03 } /* 5th */, - { ratio: 6.08, gain: 0.015 } /* 6th */, - { ratio: 8.13, gain: 0.008 } /* 8th */, - ], - attack: 0.004, - decay: 0.12, - sustainLevel: 0.18 /* piano strings continue decaying — no flat sustain */, - releaseDecay: 1.8, - }, - electric: { - /* Rhodes-style: mellow mid harmonics, slightly warmer detune */ - harmonics: [ - { ratio: 1.0, gain: 0.42 }, - { ratio: 1.0, gain: 0.08, detune: 3 }, - { ratio: 2.002, gain: 0.28 }, - { ratio: 3.005, gain: 0.15 }, - { ratio: 4.01, gain: 0.08 }, - { ratio: 5.02, gain: 0.04 }, - ], - attack: 0.012, - decay: 0.2, - sustainLevel: 0.25, - releaseDecay: 1.2, - }, - organ: { - /* Hammond-style: perfectly harmonic, steady sustain — NO decay */ - harmonics: [ - { ratio: 1.0, gain: 0.38 }, - { ratio: 2.0, gain: 0.28 }, - { ratio: 3.0, gain: 0.2 }, - { ratio: 4.0, gain: 0.1 }, - { ratio: 6.0, gain: 0.04 }, - ], - attack: 0.006, - decay: 0.01, - sustainLevel: 0.45, - releaseDecay: 0.06, - }, - harpsichord: { - /* Sharp percussive attack, fast decay, bright upper harmonics */ - harmonics: [ - { ratio: 1.0, gain: 0.48 }, - { ratio: 2.002, gain: 0.26 }, - { ratio: 4.008, gain: 0.16 }, - { ratio: 8.02, gain: 0.08 }, - { ratio: 16.05, gain: 0.03 }, - ], - attack: 0.003, - decay: 0.03, - sustainLevel: 0.06, - releaseDecay: 0.5, - }, - felt: { - /* Felt/soft piano: muted, warm — piano with felt strip on strings */ - harmonics: [ - { ratio: 1.0, gain: 0.48 }, - { ratio: 2.002, gain: 0.14 }, - { ratio: 3.01, gain: 0.06 }, - { ratio: 4.02, gain: 0.03 }, - ], - attack: 0.008, - decay: 0.18, - sustainLevel: 0.22, - releaseDecay: 2.4, - }, - bright: { - /* Bright piano: strong upper harmonics, crisp attack */ - harmonics: [ - { ratio: 1.0, gain: 0.42 }, - { ratio: 1.0, gain: 0.09, detune: 7 }, - { ratio: 2.005, gain: 0.26 }, - { ratio: 3.02, gain: 0.16 }, - { ratio: 4.04, gain: 0.12 }, - { ratio: 5.08, gain: 0.08 }, - { ratio: 6.12, gain: 0.05 }, - { ratio: 8.2, gain: 0.03 }, - ], - attack: 0.002, - decay: 0.08, - sustainLevel: 0.14, - releaseDecay: 1.5, - }, - symphonic: { - /* Symphonic / Concert Grand: rich overtones, long tail */ - harmonics: [ - { ratio: 1.0, gain: 0.45 }, - { ratio: 1.0, gain: 0.12, detune: 4 }, - { ratio: 2.004, gain: 0.24 }, - { ratio: 3.012, gain: 0.14 }, - { ratio: 4.025, gain: 0.09 }, - { ratio: 5.045, gain: 0.05 }, - { ratio: 6.07, gain: 0.03 }, - { ratio: 7.1, gain: 0.02 }, - ], - attack: 0.005, - decay: 0.15, - sustainLevel: 0.2, - releaseDecay: 3.2, - }, - violin: { - /* Violin: slow bow attack, flat sustain, expressive tail */ - harmonics: [ - { ratio: 1.0, gain: 0.4 }, - { ratio: 2.0, gain: 0.3 }, - { ratio: 3.0, gain: 0.18 }, - { ratio: 4.0, gain: 0.08 }, - { ratio: 5.0, gain: 0.04 }, - ], - attack: 0.08, - decay: 0.05, - sustainLevel: 0.42, - releaseDecay: 0.3, - }, - harp: { - /* Harp: plucked, clean fast attack, warm decay */ - harmonics: [ - { ratio: 1.0, gain: 0.5 }, - { ratio: 2.001, gain: 0.22 }, - { ratio: 3.004, gain: 0.12 }, - { ratio: 4.009, gain: 0.07 }, - { ratio: 5.016, gain: 0.04 }, - { ratio: 6.025, gain: 0.02 }, - ], - attack: 0.003, - decay: 0.05, - sustainLevel: 0.1, - releaseDecay: 2.8, - }, -}; - /* ═══════════════════════════════════════════════════════════════════════════ ISOLATED COMPONENT — the full interactive piano ═══════════════════════════════════════════════════════════════════════════ */ diff --git a/19dee3-virtual-try-on-application/46f065-popover/popover.gts b/19dee3-virtual-try-on-application/46f065-popover/popover.gts deleted file mode 100644 index 230e1a16..00000000 --- a/19dee3-virtual-try-on-application/46f065-popover/popover.gts +++ /dev/null @@ -1,1932 +0,0 @@ -import Component from '@glimmer/component'; -import { on } from '@ember/modifier'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import { modifier } from 'ember-modifier'; -import { - arrow, - autoUpdate, - computePosition, - flip, - hide, - limitShift, - offset, - shift, - size, -} from '@floating-ui/dom'; -import type { Placement, Strategy } from '@floating-ui/dom'; - -import { SURFACE_LAYERS, type SurfaceLayerTier } from './utils/layer-manager'; -import { - POPOVER_KIND_GLYPHS, - POPOVER_KIND_LABELS, - resolvePopoverEscalationTarget, - type PopoverKind, - type PopoverAnchoring, - type PopoverSize, - type PopoverBackdrop, - type PopoverElevation, - type PopoverKeyboardModel, -} from './utils/popover-types'; - -/** - * `` — anchored floating surface that hosts a focused - * interaction next to a source element. It RAISES a focused something - * out of the source's small footprint without taking the user away - * from the source. - * - * **Four orthogonal dimensions** drive the visual + behavioral - * variant: - * - * kind 'details' | 'edit' | 'tools' - * anchoring 'beside' | 'overlay' | 'center' - * size 'compact' | 'comfortable' | 'spacious' | 'auto' - * backdrop 'none' | 'tint' | 'blur' | 'dim' - * elevation 'flat' | 'raised' | 'elevated' | 'floating' - * - * Plus `keyboardModel` ('pick' | 'edit') which doesn't paint, but - * decides what to autofocus + which keystrokes the popover delegates: - * 'pick' targets a [role=listbox] (arrow-nav keys), 'edit' targets the - * editor input (typing/caret keys). - * - * **What the host owns.** Open / close state, kind state, the - * actual content (one named block per kind: `<:details>` / `<:edit>` - * / `<:tools>` — the popover renders the block matching the current - * `@kind`), what each kind means in the host's domain. The Popover - * owns positioning, dismissal plumbing (Esc + click-out), focus - * enter / restore, the per-kind + per-elevation visual chrome, and - * the optional dim backdrop. - * - * **Chrome simplification.** The escalation toolbar is OFF by - * default. When `canEscalateTo` lists more than the current kind, - * a single compact glyph button appears in the top-right corner - * (✎ for edit, ⓘ for details, etc.). One click escalates. No - * labels, no full toolbar — frees the body for content. - * - * **Dependencies.** The only npm import is `@floating-ui/dom`, which - * is globally available to every Boxel realm card via the host's - * externals shim — no install step is required to remix this. - */ - -// The popover type vocabulary (kind / anchoring / size / backdrop / -// elevation / keyboardModel) plus the kind→glyph / kind→label maps and -// escalation resolver live in ../utils/popover-types.ts (imported above). -// PopoverSignature stays here — it's the component's own args contract. - -export interface PopoverSignature { - Args: { - /** CSS selector velcro / shadowAnchor uses to find the source. */ - anchor: string; - /** When false, popover is unmounted. Toggling preserves the - * surrounding `` invocation so re-opens are cheap. */ - open: boolean; - /** Popover kind — drives the per-kind chrome variant and selects - * which named block renders (`<:details>` / `<:edit>` / `<:tools>`). - * Supply a block for every kind reachable via `@kind` or - * `@canEscalateTo`; a missing block renders an empty pane. */ - kind: PopoverKind; - /** How the popover relates to its anchor — the mounting strategy. - * 'beside' floats beside it (Floating UI), 'overlay' overlays its - * box, 'center' is a viewport-centered modal. Default 'beside'. - * (Distinct from `@placement`, which is the Floating UI side.) */ - anchoring?: PopoverAnchoring; - /** Size class. Independent of kind. Default 'compact'. Drives - * min / max width + height via CSS variables. */ - size?: PopoverSize; - /** Surface material. Independent of kind. Default 'none' (solid). */ - backdrop?: PopoverBackdrop; - /** Elevation tier (shadow + radius). Independent of kind/anchoring. - * Default 'raised'. */ - elevation?: PopoverElevation; - /** Which inner control the popover focuses on open + delegates keys - * to: 'pick' (a [role=listbox], arrow-navigated) or 'edit' (the - * editor input). Default 'edit'. Also exposed as a data attribute - * for inner primitives to read. */ - keyboardModel?: PopoverKeyboardModel; - /** Stable per-open token. Re-renders of the same open popover keep - * this token so autofocus runs once for the open interaction, - * not after every source-data update. */ - focusToken?: string | number; - /** Move DOM focus into the popover on open + restore on close. - * Default true except for `details` kind. */ - autoFocus?: boolean; - /** Trap Tab focus inside the popover (aria-modal behaviour). Off by - * default — turn on for editor popovers that own the focus cycle. */ - trapFocus?: boolean; - /** Optional kinds the user can escalate to. When the array - * contains kinds OTHER than the current `@kind`, a corner - * escalation glyph button appears. Single-kind contracts - * (just the current kind) get NO chrome. */ - canEscalateTo?: PopoverKind[]; - /** Fired when the user clicks an escalation glyph. */ - onEscalate?: (next: PopoverKind) => void; - /** Fired on Esc / outside-click. Host sets `@open=false`. */ - onDismiss?: () => void; - /** Optional explicit surface layer tier. Defaults from placement/elevation. */ - layerTier?: SurfaceLayerTier; - /** Optional fixed z-index for hosts that already allocated a layer. */ - zIndex?: number; - /** Floating UI placement — the preferred side + alignment (e.g. - * 'bottom-start', 'top-end'). Only used when anchoring is - * 'beside'. Default 'bottom-start'. */ - placement?: Placement; - /** Gap in px between the anchor and the popover (Floating UI's - * `offset`). Only used when anchoring is 'beside'. Default 8. */ - offset?: number; - /** Show a small caret pointing at the anchor. Only for 'beside' - * anchoring (Floating UI's `arrow` middleware). Default false. */ - arrow?: boolean; - /** ARIA role for the popover root. Default derived from kind: - * 'details' → 'tooltip', everything else → 'dialog'. */ - role?: string; - /** Accessible name (sets aria-label). Prefer `@labelledby` when the - * body already has a visible heading element. */ - label?: string; - /** id of the element labeling the popover (sets aria-labelledby). */ - labelledby?: string; - /** id of the element describing the popover (sets aria-describedby). */ - describedby?: string; - /** Visual scale multiplier for the popover surface. Generalizes - * the "scale of the rendering environment" — used by any - * scalable host (canvas zoom, 3D scene camera distance) to scale - * the popover in lockstep with how the rest of the host's content - * is being scaled. - * - * The host computes a DAMPED multiplier (the popover shouldn't - * scale 1:1 with the env — at canvas zoom 0.25 you don't want - * a popover at 25% of normal size, you want it noticeably - * smaller but still readable). - * - * Applied via a `transform: scale(...)` with the origin pinned to - * the top-left corner so velcro's anchor positioning still reads - * the new bbox correctly. Default 1 (no scaling — viewport scale). - * - * IGNORED for `'center'` placement (modal popovers are centered on - * the viewport, not anchored to scalable host content; they - * always render at viewport scale). */ - relativeScale?: number; - }; - Blocks: { - details: []; - edit: []; - tools: []; - }; - Element: HTMLDivElement; -} - -/** Esc / click-out dismiss modifier. - * - * Capture-phase listeners — they fire BEFORE any bubble-phase - * handler in the popover body OR in the host's surrounding shell. - * Both paths call `stopPropagation()` so the same Esc / pointerdown - * doesn't ALSO trigger the host's grid-key handler (clearing cell - * focus) or the next cell's openEdit (when the user clicked from - * one popover directly into another cell). The popover owns dismissal, - * full stop. */ -const dismissOnOutside = modifier( - (_el: HTMLElement, [onDismiss]: [(() => void) | undefined]) => { - if (!onDismiss) return; - const onPointer = (event: PointerEvent): void => { - const target = event.target as Element | null; - if (!target) return; - // Click inside any popover body OR on a popover anchor — let it - // through (the anchor click reopens a fresh popover; the body - // click is interactive). Otherwise the click is "outside" — - // dismiss + don't let the click also fire other handlers - // (e.g., a sibling cell's onSelect). Without this, clicking - // from one cell's open popover into another cell would close - // popover A then immediately open popover B with stale focus. - if (target.closest('[data-bx-popover]')) return; - if (target.closest('[data-bx-popover-anchor]')) return; - // ember-power-select renders its dropdown options in a portal at - // document.body — treat that portal as "inside" so picking an - // option from a BoxelSelect within the popover does not dismiss it. - if (target.closest('.ember-basic-dropdown-content')) return; - onDismiss(); - }; - const onKey = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - event.preventDefault(); - // Stop here — don't let Esc bubble past the popover to the - // host's keyboard handler (which would clear cell focus - // OR cancel an unrelated state). Esc inside a popover means - // ONE thing: close THIS popover. - event.stopPropagation(); - onDismiss(); - } - }; - window.addEventListener('pointerdown', onPointer, true); - window.addEventListener('keydown', onKey, true); - return () => { - window.removeEventListener('pointerdown', onPointer, true); - window.removeEventListener('keydown', onKey, true); - }; - }, -); - -const allocatePopoverLayer = modifier( - ( - element: HTMLElement, - [tier, fixedZIndex]: [SurfaceLayerTier, number | undefined], - ) => { - const z = fixedZIndex ?? SURFACE_LAYERS.allocate(tier); - element.style.setProperty('--bx-popover-z', String(z)); - element.dataset['surfaceLayerTier'] = tier; - element.dataset['surfaceLayerZ'] = String(z); - - return () => { - if (fixedZIndex === undefined) { - SURFACE_LAYERS.release(z); - } - element.style.removeProperty('--bx-popover-z'); - delete element.dataset['surfaceLayerTier']; - delete element.dataset['surfaceLayerZ']; - }; - }, -); - -/** Theme tokens the popover carries across its portal. - * - * Theme CSS variables are scoped to the CardContainer of the themed - * card (extractCssVariables applies the `:root` block there, not to - * the real document root). The popover portals into document.body — - * outside that scope — so a plain `var(--popover)` on the portaled - * root would resolve to nothing. This modifier reads the RESOLVED - * values from the anchor element (which lives inside the themed card) - * and copies them onto the portaled root as inline custom properties, - * so the popover follows whatever theme governs its anchor — including - * dark mode and Brand Guide custom variables. */ -const POPOVER_THEME_BRIDGE_TOKENS = [ - /* semantic theme tokens (shadcn vocabulary) */ - '--popover', - '--popover-foreground', - '--foreground', - '--background', - '--border', - '--primary', - '--muted-foreground', - '--radius', - '--shadow-sm', - '--shadow-md', - '--shadow-xl', - '--font-sans', - /* popover-specific knobs a host or theme may set (e.g. via Brand - * Guide custom variables) */ - '--bx-popover-bg', - '--bx-popover-fg', - '--bx-popover-fg-muted', - '--bx-popover-border', - '--bx-popover-accent', - '--bx-popover-dim-bg', - '--bx-popover-bg-tint', - '--bx-popover-bg-blur', - '--bx-popover-tools-bg', - '--bx-popover-tools-fg', - '--bx-popover-edit-bg', - '--bx-popover-edit-border', - '--bx-popover-radius', - '--bx-popover-shadow-raised', - '--bx-popover-shadow-elevated', - '--bx-popover-shadow-floating', - '--bx-popover-font-family', - '--bx-popover-size-compact-min-w', - '--bx-popover-size-compact-max-w', - '--bx-popover-size-compact-max-h', - '--bx-popover-size-comfortable-min-w', - '--bx-popover-size-comfortable-max-w', - '--bx-popover-size-comfortable-max-h', - '--bx-popover-size-spacious-min-w', - '--bx-popover-size-spacious-max-w', - '--bx-popover-size-spacious-max-h', -]; - -const bridgeThemeVariables = modifier( - (element: HTMLElement, [selector]: [string]) => { - const anchor = document.querySelector(selector); - if (!anchor) return; - const computed = getComputedStyle(anchor); - const applied: string[] = []; - for (const token of POPOVER_THEME_BRIDGE_TOKENS) { - const value = computed.getPropertyValue(token).trim(); - if (value) { - element.style.setProperty(token, value); - applied.push(token); - } - } - return () => { - for (const token of applied) { - element.style.removeProperty(token); - } - }; - }, -); - -/** Marks the portaled root with the surface mode / inspect / portaled - * attributes hosts and theming may key off. In this standalone build - * it no longer registers with a focus ladder or surface runtime — - * mode / inspect default to 'use' / false and can be passed - * explicitly when a host wants to drive them. */ -const popoverSurfaceRoot = modifier( - ( - element: HTMLElement, - _positional: [], - named: { - mode?: 'use' | 'change' | 'inspect'; - inspect?: boolean; - }, - ) => { - const priorMode = element.getAttribute('data-surface-mode'); - const priorInspect = element.getAttribute('data-surface-inspect'); - element.setAttribute('data-surface-mode', named.mode ?? 'use'); - element.setAttribute( - 'data-surface-inspect', - String(named.inspect ?? false), - ); - element.setAttribute('data-surface-portaled-root', 'popover'); - - return () => { - element.removeAttribute('data-surface-portaled-root'); - if (priorMode === null) element.removeAttribute('data-surface-mode'); - else element.setAttribute('data-surface-mode', priorMode); - if (priorInspect === null) - element.removeAttribute('data-surface-inspect'); - else element.setAttribute('data-surface-inspect', priorInspect); - }; - }, -); - -/** Shadow-anchor modifier — overlays the popover on the anchor's bbox. - * Sets top / left / min-width from anchor's getBoundingClientRect. - * Clamps to the viewport: if the popover's natural width would extend - * past the right edge, shifts left to keep the right edge inside. */ -const shadowAnchor = modifier((element: HTMLElement, [selector]: [string]) => { - const anchorEl = (): HTMLElement | null => - document.querySelector(selector); - const update = (): void => { - const a = anchorEl(); - if (!a) return; - const r = a.getBoundingClientRect(); - // Reset position-related styles before measuring so a previous - // run's shifts don't pollute the new computation. - element.style.position = 'absolute'; - element.style.top = `${window.scrollY + r.top}px`; - element.style.left = `${window.scrollX + r.left}px`; - element.style.minWidth = `${Math.round(r.width)}px`; - // Now measure the popover's actual width (after layout settled - // with the new min-width applied) and clamp to viewport. - requestAnimationFrame(() => { - const lr = element.getBoundingClientRect(); - const overflowRight = lr.right - window.innerWidth + 8; // 8px gutter - if (overflowRight > 0) { - const newLeft = window.scrollX + r.left - overflowRight; - element.style.left = `${Math.max(window.scrollX + 8, newLeft)}px`; - } - // Same for vertical — if extending past viewport bottom, - // shift up so we don't get cut off. - const overflowBottom = lr.bottom - window.innerHeight + 8; - if (overflowBottom > 0) { - const newTop = window.scrollY + r.top - overflowBottom; - element.style.top = `${Math.max(window.scrollY + 8, newTop)}px`; - } - }); - }; - update(); - const ro = new ResizeObserver(update); - const a = anchorEl(); - if (a) ro.observe(a); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return (): void => { - ro.disconnect(); - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; -}); - -const anchoredPopover = modifier( - ( - floatingElement: HTMLElement, - [selector]: [string], - { - placement = 'bottom', - offsetOptions = 8, - strategy = 'fixed', - }: { - placement?: Placement; - offsetOptions?: number; - strategy?: Strategy; - } = {}, - ) => { - let frame = 0; - let destroyed = false; - let lastTop = ''; - let lastLeft = ''; - let lastVisibility = ''; - - const referenceElement = (): HTMLElement | SVGElement | null => - document.querySelector(selector); - - // Round to whole device pixels so text stays crisp on hi-DPI - // screens (a fractional `top`/`left` blurs the subpixel-rendered - // glyphs). Per Floating UI's positioning guidance. - const roundByDPR = (value: number): number => { - const dpr = window.devicePixelRatio || 1; - return Math.round(value * dpr) / dpr; - }; - - // Floating UI's required baseline for a floating element: - // `width: max-content` so the box sizes to its content INSTEAD of - // wrapping against whatever width its current position happens to - // allow — wrapping would corrupt the measured rect and drift the - // anchor. The size-class `min/max-width` still cap it. - Object.assign(floatingElement.style, { - position: strategy, - width: 'max-content', - top: '0px', - left: '0px', - margin: '0', - }); - - const apply = (top: string, left: string, visibility: string): void => { - if ( - top === lastTop && - left === lastLeft && - visibility === lastVisibility - ) { - return; - } - - lastTop = top; - lastLeft = left; - lastVisibility = visibility; - Object.assign(floatingElement.style, { - top, - left, - margin: '0', - visibility, - }); - }; - - const update = async (): Promise => { - frame = 0; - const reference = referenceElement(); - if (!reference) { - apply(lastTop || '0px', lastLeft || '0px', 'hidden'); - return; - } - - // Host opts into an arrow by rendering an element with this - // marker inside the popover (only the 'beside' branch does). - const arrowEl = floatingElement.querySelector( - '[data-bx-popover-arrow]', - ); - - // Order matters: offset first (others build on the offset coords), - // then flip, then shift to nudge back into view, then size to cap - // height to the space that's actually left, then arrow (positions - // against the settled coords), and finally hide to detect a clipped - // anchor. Every overflow-detecting middleware shares 8px padding. - const middleware = [ - offset(offsetOptions), - flip({ fallbackAxisSideDirection: 'end', padding: 8 }), - shift({ limiter: limitShift(), padding: 8 }), - size({ - padding: 8, - apply({ availableHeight }) { - floatingElement.style.setProperty( - '--bx-popover-avail-h', - `${Math.max(0, Math.floor(availableHeight))}px`, - ); - }, - }), - ]; - if (arrowEl) { - // padding keeps the arrow from reaching the rounded corners. - middleware.push(arrow({ element: arrowEl, padding: 6 })); - } - middleware.push(hide({ strategy: 'referenceHidden', padding: 8 })); - - const { - middlewareData, - placement: resolvedPlacement, - x, - y, - } = await computePosition(reference, floatingElement, { - middleware, - placement, - strategy, - }); - if (destroyed) return; - - apply( - `${roundByDPR(y)}px`, - `${roundByDPR(x)}px`, - middlewareData.hide?.referenceHidden ? 'hidden' : 'visible', - ); - - // Position the arrow on the side facing the anchor. Floating UI - // gives the arrow's offset along the popover edge (x for top/bottom - // placements, y for left/right); we pin the perpendicular side so - // the arrow pokes out toward the reference. - if (arrowEl && middlewareData.arrow) { - const { x: arrowX, y: arrowY } = middlewareData.arrow; - const side = resolvedPlacement.split('-')[0]; - const staticSide = - { top: 'bottom', right: 'left', bottom: 'top', left: 'right' }[ - side - ] ?? 'bottom'; - for (const edge of ['top', 'right', 'bottom', 'left']) { - arrowEl.style.removeProperty(edge); - arrowEl.style.removeProperty(`border-${edge}`); - } - if (arrowX != null) arrowEl.style.left = `${roundByDPR(arrowX)}px`; - if (arrowY != null) arrowEl.style.top = `${roundByDPR(arrowY)}px`; - arrowEl.style.setProperty(staticSide, '-6px'); - - // CSS border-triangle technique — no rotation, no clip-path. - // A zero-size element with two transparent borders and one - // coloured border produces a clean triangle in any direction. - // staticSide is the card edge the arrow is pinned to; the tip - // points in the OPPOSITE direction (toward the anchor). - arrowEl.style.setProperty('transform', 'none'); - arrowEl.style.setProperty('background', 'none'); - arrowEl.style.setProperty('clip-path', 'none'); - arrowEl.style.setProperty('width', '0'); - arrowEl.style.setProperty('height', '0'); - const fill = 'var(--bx-popover-bg, #fff)'; - const none = '0'; - const solid = `7px solid ${fill}`; - const clear = '7px solid transparent'; - const triangles: Record> = { - top: { - 'border-top': none, - 'border-right': clear, - 'border-bottom': solid, - 'border-left': clear, - }, - bottom: { - 'border-top': solid, - 'border-right': clear, - 'border-bottom': none, - 'border-left': clear, - }, - left: { - 'border-top': clear, - 'border-right': solid, - 'border-bottom': clear, - 'border-left': none, - }, - right: { - 'border-top': clear, - 'border-right': none, - 'border-bottom': clear, - 'border-left': solid, - }, - }; - const t = triangles[staticSide]; - if (t) { - for (const [prop, val] of Object.entries(t)) { - arrowEl.style.setProperty(prop, val); - } - } - } - }; - - const schedule = (): void => { - if (frame !== 0) return; - frame = requestAnimationFrame(() => { - void update(); - }); - }; - - schedule(); - const reference = referenceElement(); - const cleanup = reference - ? autoUpdate(reference, floatingElement, schedule, { - ancestorResize: true, - ancestorScroll: true, - elementResize: true, - // Follow the anchor when surrounding layout shifts it - // (content added above it, a sibling expanding) — not just - // on scroll/resize. animationFrame stays off: it polls every - // frame and is only needed for continuously-animating anchors. - layoutShift: true, - animationFrame: false, - }) - : undefined; - - return (): void => { - destroyed = true; - cancelAnimationFrame(frame); - cleanup?.(); - }; - }, -); - -/** Focus-management modifier. Auto-focuses first focusable in body - * on mount; restores DOM focus to the closest focusable ancestor - * of the anchor on unmount. */ -const focusedPopoverTokens = new Set(); - -function popoverFocusableSelector(): string { - return [ - 'button:not([disabled]):not([tabindex="-1"])', - 'input:not([type="hidden"]):not([disabled]):not([tabindex="-1"])', - 'select:not([disabled]):not([tabindex="-1"])', - 'textarea:not([disabled]):not([tabindex="-1"])', - '[contenteditable=""]:not([tabindex="-1"])', - '[contenteditable="true"]:not([tabindex="-1"])', - '[tabindex]:not([tabindex="-1"])', - ].join(','); -} - -function popoverEditorSelector(): string { - return [ - 'input:not([type="hidden"]):not([disabled]):not([tabindex="-1"])', - 'textarea:not([disabled]):not([tabindex="-1"])', - 'select:not([disabled]):not([tabindex="-1"])', - '[contenteditable=""]:not([tabindex="-1"])', - '[contenteditable="true"]:not([tabindex="-1"])', - ].join(','); -} - -function visibleFocusables(element: HTMLElement): HTMLElement[] { - return Array.from( - element.querySelectorAll(popoverFocusableSelector()), - ).filter((candidate) => { - if (!candidate.isConnected) return false; - if (candidate.closest('[inert]')) return false; - const rects = candidate.getClientRects(); - return rects.length > 0 || candidate === document.activeElement; - }); -} - -function firstPopoverFocusTarget(element: HTMLElement): HTMLElement | null { - const body = - element.querySelector('.bx-popover__body') ?? element; - const keyboardModel = element.getAttribute('data-bx-popover-keyboard-model'); - if (keyboardModel === 'pick') { - const listbox = body.querySelector( - '[role="listbox"]:not([tabindex="-1"])', - ); - if (listbox) return listbox; - } - const autofocus = body.querySelector('[autofocus]'); - if (autofocus) return autofocus; - if (keyboardModel === 'edit') { - const editor = body.querySelector(popoverEditorSelector()); - if (editor) return editor; - } - return body.querySelector(popoverFocusableSelector()); -} - -function focusPopoverTarget(target: HTMLElement): void { - target.focus({ preventScroll: true }); - if (target instanceof HTMLInputElement) { - if ( - target.type === 'text' || - target.type === 'number' || - target.type === 'search' || - target.type === 'url' || - target.type === 'tel' || - target.type === 'email' || - target.type === 'password' - ) { - target.select(); - } - } else if (target instanceof HTMLTextAreaElement) { - target.select(); - } -} - -type ReroutedKeyboardEvent = KeyboardEvent & { - __boxelPopoverKeyboardRerouted?: true; -}; - -function isPlainTextKey(event: KeyboardEvent): boolean { - return ( - event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey - ); -} - -function isPickerNavigationKey(event: KeyboardEvent): boolean { - return ( - event.key === 'ArrowDown' || - event.key === 'ArrowUp' || - event.key === 'Home' || - event.key === 'End' || - event.key === 'Enter' || - event.key === 'Tab' || - event.key === ' ' || - event.key === 'Spacebar' || - isPlainTextKey(event) - ); -} - -function isEditingKey(event: KeyboardEvent): boolean { - if (event.metaKey || event.ctrlKey || event.altKey) return false; - return ( - event.key === 'Enter' || - event.key === 'Tab' || - event.key.startsWith('Arrow') || - event.key === 'Home' || - event.key === 'End' || - event.key === 'PageUp' || - event.key === 'PageDown' || - event.key === 'Backspace' || - event.key === 'Delete' || - isPlainTextKey(event) - ); -} - -function popoverKeyboardModelOwnsEvent( - element: HTMLElement, - event: KeyboardEvent, -): boolean { - const keyboardModel = element.getAttribute('data-bx-popover-keyboard-model'); - if (keyboardModel === 'pick') return isPickerNavigationKey(event); - if (keyboardModel === 'edit') return isEditingKey(event); - return false; -} - -function topmostKeyboardPopover(): HTMLElement | null { - const popovers = Array.from( - document.querySelectorAll( - '[data-bx-popover][data-bx-popover-keyboard-lock="true"]', - ), - ); - return ( - popovers.sort((a, b) => { - const za = Number(a.dataset['surfaceLayerZ'] ?? 0); - const zb = Number(b.dataset['surfaceLayerZ'] ?? 0); - return zb - za; - })[0] ?? null - ); -} - -function cloneKeyboardEvent(event: KeyboardEvent): ReroutedKeyboardEvent { - const next = new KeyboardEvent(event.type, { - key: event.key, - code: event.code, - location: event.location, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - repeat: event.repeat, - isComposing: event.isComposing, - bubbles: true, - cancelable: true, - }) as ReroutedKeyboardEvent; - next.__boxelPopoverKeyboardRerouted = true; - return next; -} - -const popoverFocusModifier = modifier( - ( - element: HTMLElement, - [focusToken]: [string | number | undefined], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - const initial = document.activeElement as HTMLElement | null; - const previouslyFocused = - initial && initial !== document.body ? initial : null; - const token = focusToken === undefined ? undefined : String(focusToken); - let frame = 0; - let attempts = 0; - const focusWhenReady = (): void => { - if (token && focusedPopoverTokens.has(token)) return; - // Pick model: prefer the LISTBOX (Spotlight idiom). Compose - // model: prefer the editor's own input (calendar's date input, - // formula builder's expression box). Other models: first - // focusable wins. - const target = firstPopoverFocusTarget(element); - if (!target) { - if (attempts++ < 4) { - frame = requestAnimationFrame(focusWhenReady); - } - return; - } - focusPopoverTarget(target); - if (token) focusedPopoverTokens.add(token); - }; - frame = requestAnimationFrame(focusWhenReady); - const anchorSelector = element.getAttribute( - 'data-bx-popover-anchor-selector', - ); - return (): void => { - cancelAnimationFrame(frame); - const active = document.activeElement as HTMLElement | null; - if (active?.closest('[data-bx-popover]')) return; - const focusEscaped = - active !== null && - active !== document.body && - !element.contains(active); - if (focusEscaped) return; - const isFocusable = (el: HTMLElement): boolean => { - if (el.hasAttribute('disabled')) return false; - const tag = el.tagName; - if ( - tag === 'INPUT' || - tag === 'TEXTAREA' || - tag === 'SELECT' || - tag === 'BUTTON' || - tag === 'A' - ) { - return true; - } - if (el.hasAttribute('contenteditable')) return true; - const ti = el.getAttribute('tabindex'); - if (ti !== null && ti !== '-1') return true; - return false; - }; - const findFocusable = (start: HTMLElement | null): HTMLElement | null => { - let cur: HTMLElement | null = start; - while (cur && cur !== document.body) { - if (isFocusable(cur)) return cur; - cur = cur.parentElement; - } - return start; - }; - let restoreTo: HTMLElement | null = null; - if (previouslyFocused && document.contains(previouslyFocused)) { - restoreTo = isFocusable(previouslyFocused) - ? previouslyFocused - : findFocusable(previouslyFocused); - } - if (!restoreTo && anchorSelector) { - const anchor = document.querySelector(anchorSelector); - restoreTo = findFocusable(anchor); - } - if (!restoreTo) return; - restoreTo.focus(); - setTimeout(() => { - if (restoreTo && document.contains(restoreTo)) restoreTo.focus(); - }, 0); - }; - }, -); - -/** Delegates stale-focus keyboard events into the active popover body. - * - * While an edit/tools popover is open, Arrow/Enter/Space/type-ahead - * belong to the lifted control, even if the browser still reports - * DOM focus on the source cell or parent grid. The popover focuses its - * negotiated target (`keyboardModel="pick"` prefers the listbox; - * `"edit"` prefers the editor input) and re-dispatches a cloned - * key event there. Host grids should see neither the stale event nor - * a parent navigation command. - */ -const delegatePopoverKeyboardModifier = modifier( - ( - element: HTMLElement, - _positional: never[], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - - const onKeydown = (event: KeyboardEvent): void => { - const routed = event as ReroutedKeyboardEvent; - if (routed.__boxelPopoverKeyboardRerouted) return; - if (event.defaultPrevented) return; - if (event.key === 'Escape') return; - if (topmostKeyboardPopover() !== element) return; - - const target = event.target instanceof Element ? event.target : null; - const active = - document.activeElement instanceof Element - ? document.activeElement - : null; - // Treat ember-power-select's portal as logically inside the popover — - // keystrokes in its search/options must NOT be hijacked by the popover. - const insidePopover = (node: Element): boolean => { - if (element.contains(node)) return true; - if (node.closest('.ember-basic-dropdown-content')) return true; - return false; - }; - if (target && insidePopover(target)) return; - if (active && insidePopover(active)) return; - if (!popoverKeyboardModelOwnsEvent(element, event)) return; - - const delegateTarget = firstPopoverFocusTarget(element); - if (!delegateTarget) return; - - event.preventDefault(); - event.stopImmediatePropagation(); - focusPopoverTarget(delegateTarget); - delegateTarget.dispatchEvent(cloneKeyboardEvent(event)); - }; - - window.addEventListener('keydown', onKeydown, true); - return () => window.removeEventListener('keydown', onKeydown, true); - }, -); - -/** Keeps edit/center popovers in control of DOM focus while they are open. - * - * Surface selection remains on the source coordinate; the popover owns - * the active editor. This mirrors grid/canvas lifted editing: Tab - * cycles inside the raised editor, and any programmatic focus steal - * back to the source is corrected on the next focusin/frame. */ -const trapPopoverFocusModifier = modifier( - ( - element: HTMLElement, - _positional: never[], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - - let lastFocused: HTMLElement | null = null; - let allowOutsideFocusUntil = 0; - - const focusFallback = (): void => { - requestAnimationFrame(() => { - if (!element.isConnected) return; - if ( - document.activeElement instanceof Element && - element.contains(document.activeElement) - ) { - return; - } - const target = - (lastFocused?.isConnected && element.contains(lastFocused) - ? lastFocused - : null) ?? firstPopoverFocusTarget(element); - target?.focus({ preventScroll: true }); - }); - }; - - const onKeydown = (event: KeyboardEvent): void => { - if (event.key !== 'Tab') return; - const focusables = visibleFocusables(element); - if (focusables.length === 0) return; - - event.preventDefault(); - event.stopPropagation(); - - const active = document.activeElement as HTMLElement | null; - const currentIndex = active ? focusables.indexOf(active) : -1; - const nextIndex = - currentIndex === -1 - ? 0 - : event.shiftKey - ? (currentIndex - 1 + focusables.length) % focusables.length - : (currentIndex + 1) % focusables.length; - const next = focusables[nextIndex]; - if (!next) return; - lastFocused = next; - next.focus({ preventScroll: true }); - }; - - // Treat ember-power-select's portal (rendered at document.body) as - // logically inside the popover — its dropdown options sit outside our - // element subtree but represent interaction with our content. - const isInsideOrPortal = (target: Element): boolean => { - if (element.contains(target)) return true; - if (target.closest('.ember-basic-dropdown-content')) return true; - return false; - }; - - const onFocusin = (event: FocusEvent): void => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - if (isInsideOrPortal(target)) { - lastFocused = target; - return; - } - if (Date.now() < allowOutsideFocusUntil) return; - focusFallback(); - }; - - const onPointerdown = (event: PointerEvent): void => { - const target = event.target; - if (target instanceof Element && isInsideOrPortal(target)) return; - // Outside pointerdown is normally a dismiss gesture. Give the - // close path a short window so the trap does not fight the - // user's intentional click outside the popover. - allowOutsideFocusUntil = Date.now() + 250; - }; - - element.addEventListener('keydown', onKeydown, true); - window.addEventListener('focusin', onFocusin, true); - window.addEventListener('pointerdown', onPointerdown, true); - - return () => { - element.removeEventListener('keydown', onKeydown, true); - window.removeEventListener('focusin', onFocusin, true); - window.removeEventListener('pointerdown', onPointerdown, true); - }; - }, -); - -let nextPopoverInstanceId = 0; - -const cleanupClosedPopoverModifier = modifier( - (_element: HTMLElement, [open, instanceId]: [boolean, string]) => { - let frame = 0; - if (!open) { - frame = requestAnimationFrame(() => { - for (const stale of document.querySelectorAll( - `[data-bx-popover-instance="${instanceId}"]`, - )) { - stale.remove(); - } - }); - } - - return () => { - cancelAnimationFrame(frame); - }; - }, -); - -export default class Popover extends Component { - readonly instanceId = `bx-popover-${++nextPopoverInstanceId}`; - - // ─── arg defaults ─────────────────────────────────────────────── - - get portalTarget(): HTMLElement { - if (typeof document === 'undefined') { - throw new Error(' requires a browser document to portal into.'); - } - // Portal into the host's submode layout, NOT document.body and NOT the - // operator-mode root. The reason is stacking context, not z-index value: - // - // .operator-mode (position: fixed) ← own stacking context - // .submode-layout (position: relative; ← own stacking context @ z:0 - // z-index: 0) - // …the card stack (z ~1–10) ← the card we belong to - // …host in-submode popups ← profile (z 1001), top-bar - // (700), AI panel (900) … - // CardChooserModal / boxel modals (z 1500 / 2000) - // - // The card we float over AND the host's in-submode popups (profile, - // top-bar, AI panel) both live INSIDE .submode-layout. Portaling any - // higher (operator-mode or body) puts the popover at a level that paints - // OVER the whole .submode-layout subtree — so it covers those host - // popups no matter how small its z-index is (their z is trapped inside - // submode-layout's z:0 context). Portaling INTO .submode-layout puts the - // popover in the same stacking context as them, so its compressed tier - // (z < 200, see SurfaceLayerManager) correctly sits ABOVE the card stack - // yet BELOW every host surface — the in-submode popups here, and the - // operator-mode-level modals (which sit above the whole submode subtree). - // Falls back outward when a level is absent (code mode, standalone, tests). - return ( - document.querySelector('.submode-layout') ?? - document.querySelector('.operator-mode') ?? - document.body - ); - } - - get effectivePlacement(): Placement { - return this.args.placement ?? 'bottom'; - } - - get offsetDistance(): number { - return this.args.offset ?? 8; - } - - get anchoring(): PopoverAnchoring { - return this.args.anchoring ?? 'beside'; - } - - // ── Visual dials: each resolves ONLY its own arg + a fixed default. - // No dial reads kind or anchoring, so the axes stay orthogonal — set - // one and nothing else moves. (Want the old "edit looks blurred + - // elevated" preset? Set @backdrop / @elevation explicitly.) - - get size(): PopoverSize { - return this.args.size ?? 'compact'; - } - - get backdrop(): PopoverBackdrop { - return this.args.backdrop ?? 'none'; - } - - get elevation(): PopoverElevation { - return this.args.elevation ?? 'raised'; - } - - get keyboardModel(): PopoverKeyboardModel | undefined { - return this.args.keyboardModel; - } - - get isOverlay(): boolean { - return this.anchoring === 'overlay'; - } - - get isCenter(): boolean { - return this.anchoring === 'center'; - } - - /** A caret only makes sense for a 'beside' popover that floats off - * its anchor — not for an overlay (covers the source) or a centered - * modal (not anchored). */ - get hasArrow(): boolean { - return Boolean(this.args.arrow) && this.anchoring === 'beside'; - } - - get hasDim(): boolean { - return this.backdrop === 'dim'; - } - - /** Default autoFocus policy. */ - get shouldAutoFocus(): boolean { - if (this.args.autoFocus !== undefined) return this.args.autoFocus; - return this.args.kind !== 'details'; - } - - /** ARIA role for the popover root. details is a passive - * tooltip; every interactive kind is a dialog. Host can override - * via `@role` (e.g. 'menu' for an action list). */ - get role(): string { - if (this.args.role) return this.args.role; - return this.args.kind === 'details' ? 'tooltip' : 'dialog'; - } - - /** Trap focus + aria-modal. Driven by @trapFocus, not kind. */ - get shouldTrapFocus(): boolean { - return this.args.trapFocus ?? false; - } - - get isModal(): boolean { - return this.shouldTrapFocus; - } - - /** Delegate keyboard events into the popover body. Active when the - * host explicitly passes @keyboardModel — that signals there is an - * inner pick/edit target that owns key events. */ - get shouldDelegateKeyboard(): boolean { - return this.args.keyboardModel !== undefined; - } - - get layerTier(): SurfaceLayerTier { - if (this.args.layerTier) return this.args.layerTier; - // A dim dims the whole page — that's a modal affordance, so the - // popover (and its dim) belong in the modal tier, above other - // floating UI. Without this, an `beside` popover sits in the - // 'popover' tier (z ~1000) while the dim defaults near 10000 and - // would render ON TOP of its own popover, blurring it out. - if (this.hasDim) return 'modal'; - if (this.anchoring === 'center') return 'modal'; - if (this.anchoring === 'overlay') return 'cell-lift'; - return 'popover'; - } - - /** Inline style string for the popover root. Carries the optional - * `relativeScale` arg as a `transform: scale(...)` with origin - * pinned to the popover's top-left corner. - * - * WHY NOT CSS `zoom`: the CSS `zoom` property scales ALL element - * dimensions — INCLUDING positional `top` / `left` written by the - * anchored positioning modifier. So a popover with `top: 200px` and - * `zoom: 0.8` actually paints at `top: 160px`, jumping it away - * from its anchor. - * - * WHY transform + top-left origin: the anchor modifier uses Floating UI's - * `computePosition` which already reads `getBoundingClientRect` - * (which RETURNS post-transform coords), so the scaled popover's - * apparent box is what the positioner sizes against. With - * `transform-origin: top left`, the visual top-left of the scaled - * box stays exactly at the `top: y; left: x;` point — for - * `bottom-start` placement that's the cell's bottom-left, which - * is what we want. - * - * Center placement gets NO scale (center is a viewport modal, not - * an anchored surface; it always renders at viewport scale). */ - get rootStyle(): string { - const z = this.args.relativeScale; - if (z === undefined || z === 1) return ''; - // Only beside popovers honor relativeScale. Center is a viewport - // modal — not anchored to scalable host content. Shadow already - // overlays the source cell which is itself in host coords (the - // shadowAnchor modifier reads cell's screen bbox, which already - // reflects canvas zoom), so an extra scale would double-apply. - if (this.anchoring !== 'beside') return ''; - // Hard safety clamp — a host that does its own math could send - // something extreme. We don't want layout to explode either way. - const clamped = Math.max(0.4, Math.min(2.5, z)); - return `transform: scale(${clamped}); transform-origin: top left;`; - } - - // ─── classes ──────────────────────────────────────────────────── - - /** Composite class for the popover root. Includes kind + placement - * + size + backdrop + elevation. CSS reads these as orthogonal - * modifiers (see styles below). */ - get popoverClass(): string { - return [ - 'bx-popover', - `bx-popover--${this.args.kind}`, - `bx-popover--placement-${this.anchoring}`, - `bx-popover--size-${this.size}`, - `bx-popover--backdrop-${this.backdrop}`, - `bx-popover--elevation-${this.elevation}`, - ].join(' '); - } - - // ─── escalation glyph ────────────────────────────────────────── - - /** Other kinds the user can escalate to (filtered to exclude - * the current one). When empty, no escalation chrome renders. */ - get escalationTargets(): PopoverKind[] { - return (this.args.canEscalateTo ?? []).filter((k) => k !== this.args.kind); - } - - /** The single kind the corner glyph escalates to: the highest-priority - * available target (see POPOVER_ESCALATION_PRIORITY). One source of - * truth for the glyph, the label, and the click — they never disagree. */ - get primaryEscalationTarget(): PopoverKind | undefined { - return resolvePopoverEscalationTarget(this.escalationTargets); - } - - get hasEscalation(): boolean { - return this.primaryEscalationTarget != null && this.args.onEscalate != null; - } - - /** Glyph for the corner escalation button — the primary target's glyph - * (e.g. ✎ when edit is offered). Never a generic kebab while a real - * target exists, so the affordance reads as "lift to ". */ - get escalationGlyph(): string { - const target = this.primaryEscalationTarget; - return target ? this.kindGlyph(target) : '⋯'; - } - - /** Aria-label for the corner escalation button. */ - get escalationLabel(): string { - const target = this.primaryEscalationTarget; - return target - ? `Switch to ${this.kindLabel(target)}` - : 'Switch popover mode'; - } - - /** Click handler for the corner glyph — escalates to the same primary - * target the glyph depicts. */ - fireEscalateNext = (): void => { - const target = this.primaryEscalationTarget; - if (target) this.args.onEscalate?.(target); - }; - - kindLabel(kind: PopoverKind): string { - return POPOVER_KIND_LABELS[kind]; - } - - kindGlyph(kind: PopoverKind): string { - return POPOVER_KIND_GLYPHS[kind]; - } - - /** Dim click — fires onDismiss if provided. Bound here so the - * template can wire it without `(fn ...)` plumbing. */ - handleDimClick = (): void => { - this.args.onDismiss?.(); - }; - - // Modifiers exposed on instance for Glint strict mode. - anchoredPopover = anchoredPopover; - shadowAnchor = shadowAnchor; - popoverFocus = popoverFocusModifier; - trapPopoverFocus = trapPopoverFocusModifier; - delegatePopoverKeyboard = delegatePopoverKeyboardModifier; - dismissOnOutside = dismissOnOutside; - allocatePopoverLayer = allocatePopoverLayer; - popoverSurfaceRoot = popoverSurfaceRoot; - bridgeThemeVars = bridgeThemeVariables; - cleanupClosedPopover = cleanupClosedPopoverModifier; - - -} diff --git a/19dee3-virtual-try-on-application/46f065-popover/utils/layer-manager.gts b/19dee3-virtual-try-on-application/46f065-popover/utils/layer-manager.gts deleted file mode 100644 index 56471454..00000000 --- a/19dee3-virtual-try-on-application/46f065-popover/utils/layer-manager.gts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * SurfaceLayerManager — dynamic z-index allocator for surface layers. - * - * Static z-index tokens declare where each layer tier sits, but they do not - * order multiple active surfaces inside one tier. A fresh allocation on mount - * gives nested popovers, cell lifts, modals, drag ghosts, and future top-layer - * bridges deterministic stacking without every host inventing its own ladder. - */ - -export type SurfaceLayerTier = - | 'selection' - | 'cell-lift' - | 'popover' - | 'modal' - | 'toast'; - -export interface SurfaceLayerRect { - id?: string; - left: number; - top: number; - width: number; - height: number; - radius?: SurfaceLayerCornerRadii; -} - -export interface SurfaceLayerCornerRadii { - topLeft: number; - topRight: number; - bottomRight: number; - bottomLeft: number; -} - -export interface SurfaceLayerBox { - ids: string[]; - left: number; - top: number; - right: number; - bottom: number; - width: number; - height: number; - radius?: SurfaceLayerCornerRadii; -} - -export interface SurfaceLayerClipBounds { - left: number; - top: number; - right: number; - bottom: number; -} - -export interface SurfaceLayerBoxCollapseOptions { - /** Pixel tolerance for adjacent DOM rects that differ by subpixel rounding. */ - tolerance?: number; -} - -// The whole ladder lives in the z-index window (700, 900): ABOVE the host's -// persistent top-bar chrome (--host-top-bar-z-index = 700) but BELOW the host -// popups/modals that can co-occur with an open card — the AI-panel popover -// (--host-ai-panel-popover-z-index = 900), the profile popover -// (--boxel-layer-floating-button + 1 = 1001), and boxel-ui modals / the -// card-chooser (1500 / 2000). -// -// Why above the top bar (not below all host chrome): the top bar is persistent -// chrome, not a dismissable popup. Sitting BELOW it (as an earlier, more -// aggressive compression did) means a tall popover slides UP under the bar and -// gets visually CROPPED. Sitting just ABOVE it makes that crop structurally -// impossible — the popover simply paints over the bar, the same way the host's -// own AI-chat popover (anchored into the top bar) already does — while still -// staying under every real popup/modal. -// -// (This z ordering only holds because the popover portals into the SAME -// stacking context as that host chrome — see Popover#portalTarget; a z-index -// is meaningless across stacking contexts.) Ordering within the ladder is -// preserved (selection < cell-lift < popover < modal < toast) for catalog -// surfaces that stack among themselves. -const TIER_BASE: Record = { - selection: 705, - 'cell-lift': 715, - popover: 740, - modal: 800, - toast: 860, -}; - -const TIER_CEILING: Record = { - selection: 714, - 'cell-lift': 739, - popover: 799, - modal: 859, - toast: 899, -}; - -export class SurfaceLayerManager { - private active = new Map(); - - allocate(tier: SurfaceLayerTier): number { - const base = TIER_BASE[tier]; - const ceiling = TIER_CEILING[tier]; - let z = base; - while (this.active.has(z) && z < ceiling) z++; - if (z >= ceiling) { - console.warn( - `[SurfaceLayerManager] Tier '${tier}' exhausted ` + - `(${ceiling - base} active). Returning ceiling.`, - ); - } - this.active.set(z, tier); - return z; - } - - release(z: number): void { - this.active.delete(z); - } - - get top(): number { - if (this.active.size === 0) return 0; - return Math.max(...this.active.keys()); - } - - get snapshot(): ReadonlyMap { - return new Map(this.active); - } - - countByTier(): Record { - const out: Record = { - selection: 0, - 'cell-lift': 0, - popover: 0, - modal: 0, - toast: 0, - }; - for (const tier of this.active.values()) out[tier]++; - return out; - } - - _resetForTests(): void { - this.active.clear(); - } - - collapseSelectionBoxes( - rects: readonly SurfaceLayerRect[], - options: SurfaceLayerBoxCollapseOptions = {}, - ): SurfaceLayerBox[] { - return collapseSurfaceLayerBoxes(rects, options); - } -} - -export const SURFACE_LAYERS = new SurfaceLayerManager(); - -// Back-compat names for code that adopted the grid POC vocabulary. -export const LAYERS = SURFACE_LAYERS; -export { SurfaceLayerManager as LayerManager }; -export type { SurfaceLayerTier as PopoverTier }; - -export function collapseSurfaceLayerBoxes( - rects: readonly SurfaceLayerRect[], - options: SurfaceLayerBoxCollapseOptions = {}, -): SurfaceLayerBox[] { - const tolerance = options.tolerance ?? 1; - const normalized = rects - .map((rect, index) => { - const left = rect.left; - const top = rect.top; - const right = rect.left + rect.width; - const bottom = rect.top + rect.height; - return { - ids: [rect.id ?? String(index)], - left, - top, - right, - bottom, - width: rect.width, - height: rect.height, - ...(rect.radius - ? { - radius: clampSurfaceLayerRadii( - rect.radius, - rect.width, - rect.height, - ), - } - : {}), - }; - }) - .filter( - (box) => - Number.isFinite(box.left) && - Number.isFinite(box.top) && - box.width > 0 && - box.height > 0, - ) - .sort((a, b) => a.top - b.top || a.left - b.left); - - if (normalized.length <= 1) return normalized; - - const rowBands: SurfaceLayerBox[][] = []; - for (const box of normalized) { - const row = rowBands.find((band) => - overlapsVertically(band[0]!, box, tolerance), - ); - if (row) row.push(box); - else rowBands.push([box]); - } - - const rowBoxes: SurfaceLayerBox[] = []; - for (const band of rowBands) { - band.sort((a, b) => a.left - b.left); - let current: SurfaceLayerBox | null = null; - for (const box of band) { - if (!current) { - current = cloneBox(box); - } else if (box.left <= current.right + tolerance) { - current = unionBoxes(current, box); - } else { - rowBoxes.push(current); - current = cloneBox(box); - } - } - if (current) rowBoxes.push(current); - } - - rowBoxes.sort((a, b) => a.left - b.left || a.top - b.top); - const collapsed: SurfaceLayerBox[] = []; - for (const box of rowBoxes) { - const match = collapsed.find( - (candidate) => - nearlyEqual(candidate.left, box.left, tolerance) && - nearlyEqual(candidate.right, box.right, tolerance) && - box.top <= candidate.bottom + tolerance, - ); - if (match) { - const next = unionBoxes(match, box); - Object.assign(match, next); - } else { - collapsed.push(cloneBox(box)); - } - } - - return collapsed.sort((a, b) => a.top - b.top || a.left - b.left); -} - -export function clipSurfaceLayerRect( - rect: SurfaceLayerRect, - clip: SurfaceLayerClipBounds, -): SurfaceLayerRect | null { - const rectRight = rect.left + rect.width; - const rectBottom = rect.top + rect.height; - const left = Math.max(rect.left, clip.left); - const top = Math.max(rect.top, clip.top); - const right = Math.min(rectRight, clip.right); - const bottom = Math.min(rectBottom, clip.bottom); - - if (right <= left || bottom <= top) return null; - - const clippedLeft = left > rect.left; - const clippedTop = top > rect.top; - const clippedRight = right < rectRight; - const clippedBottom = bottom < rectBottom; - const width = right - left; - const height = bottom - top; - - return { - ...rect, - left, - top, - width, - height, - ...(rect.radius - ? { - radius: clampSurfaceLayerRadii( - { - topLeft: clippedLeft || clippedTop ? 0 : rect.radius.topLeft, - topRight: clippedRight || clippedTop ? 0 : rect.radius.topRight, - bottomRight: - clippedRight || clippedBottom ? 0 : rect.radius.bottomRight, - bottomLeft: - clippedLeft || clippedBottom ? 0 : rect.radius.bottomLeft, - }, - width, - height, - ), - } - : {}), - }; -} - -function overlapsVertically( - a: SurfaceLayerBox, - b: SurfaceLayerBox, - tolerance: number, -): boolean { - if (nearlyEqual(a.top, b.top, tolerance)) return true; - const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); - return overlap > Math.min(a.height, b.height) / 2; -} - -function nearlyEqual(a: number, b: number, tolerance: number): boolean { - return Math.abs(a - b) <= tolerance; -} - -function cloneBox(box: SurfaceLayerBox): SurfaceLayerBox { - return { - ids: [...box.ids], - left: box.left, - top: box.top, - right: box.right, - bottom: box.bottom, - width: box.width, - height: box.height, - ...(box.radius ? { radius: { ...box.radius } } : {}), - }; -} - -function unionBoxes(a: SurfaceLayerBox, b: SurfaceLayerBox): SurfaceLayerBox { - const left = Math.min(a.left, b.left); - const top = Math.min(a.top, b.top); - const right = Math.max(a.right, b.right); - const bottom = Math.max(a.bottom, b.bottom); - return { - ids: [...a.ids, ...b.ids], - left, - top, - right, - bottom, - width: right - left, - height: bottom - top, - ...mergedSurfaceLayerRadius(a.radius, b.radius, right - left, bottom - top), - }; -} - -function mergedSurfaceLayerRadius( - a: SurfaceLayerCornerRadii | undefined, - b: SurfaceLayerCornerRadii | undefined, - width: number, - height: number, -): { radius?: SurfaceLayerCornerRadii } { - if (!a || !b) return {}; - - return { - radius: clampSurfaceLayerRadii( - { - topLeft: Math.min(a.topLeft, b.topLeft), - topRight: Math.min(a.topRight, b.topRight), - bottomRight: Math.min(a.bottomRight, b.bottomRight), - bottomLeft: Math.min(a.bottomLeft, b.bottomLeft), - }, - width, - height, - ), - }; -} - -function clampSurfaceLayerRadii( - radius: SurfaceLayerCornerRadii, - width: number, - height: number, -): SurfaceLayerCornerRadii { - const max = Math.max(0, Math.min(width, height) / 2); - return { - topLeft: clampRadius(radius.topLeft, max), - topRight: clampRadius(radius.topRight, max), - bottomRight: clampRadius(radius.bottomRight, max), - bottomLeft: clampRadius(radius.bottomLeft, max), - }; -} - -function clampRadius(value: number, max: number): number { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(value, max)); -} diff --git a/19dee3-virtual-try-on-application/46f065-popover/utils/popover-types.gts b/19dee3-virtual-try-on-application/46f065-popover/utils/popover-types.gts deleted file mode 100644 index 1282aa6d..00000000 --- a/19dee3-virtual-try-on-application/46f065-popover/utils/popover-types.gts +++ /dev/null @@ -1,59 +0,0 @@ -// Shared TypeScript vocabulary for `` — the kind/axis unions plus -// the kind→glyph / kind→label maps and the escalation-priority resolver. -// Pulled out of the component so hosts, the playground, and the popover -// state machine import ONE canonical source instead of reaching into the -// `.gts`. Pure types + data; no Glimmer, no DOM. - -export type PopoverKind = 'details' | 'edit' | 'tools'; - -export type PopoverAnchoring = 'beside' | 'overlay' | 'center'; - -export type PopoverSize = 'compact' | 'comfortable' | 'spacious' | 'auto'; - -export type PopoverBackdrop = 'none' | 'tint' | 'blur' | 'dim'; - -export type PopoverElevation = 'flat' | 'raised' | 'elevated' | 'floating'; - -export type PopoverKeyboardModel = 'pick' | 'edit'; - -/** Glyph for each kind — the SINGLE source of truth. Hosts and - * playgrounds import this instead of hard-coding which icon means what: - * ✎ edit, ⓘ details, ⋯ tools. */ -export const POPOVER_KIND_GLYPHS: Record = { - details: 'ⓘ', - edit: '✎', - tools: '⋯', -}; - -/** Human label for each kind — paired with POPOVER_KIND_GLYPHS so the - * glyph and its name never drift apart. */ -export const POPOVER_KIND_LABELS: Record = { - details: 'Details', - edit: 'Edit', - tools: 'Tools', -}; - -/** Escalation ladder — the order the corner glyph prefers when a - * contract offers more than one target. Lifting a passive surface up - * to an EDITABLE one is the most common escalation, so 'edit' wins; - * 'tools' is the heavier action surface; 'details' is the passive - * fallback. The single highest-priority available target drives the - * glyph, the aria-label, AND the click action — so the icon always - * tells the truth about where the click goes. */ -export const POPOVER_ESCALATION_PRIORITY: readonly PopoverKind[] = [ - 'edit', - 'tools', - 'details', -]; - -/** Resolve which kind a corner glyph escalates to, given the offered - * targets (already filtered of the current kind). Highest-priority - * wins; falls back to the first offered. Shared so the component and - * any host agree on the destination without re-implementing it. */ -export function resolvePopoverEscalationTarget( - targets: PopoverKind[], -): PopoverKind | undefined { - return ( - POPOVER_ESCALATION_PRIORITY.find((k) => targets.includes(k)) ?? targets[0] - ); -} diff --git a/25f2fc-homework-grader/Spec/290b04c0-7901-4332-bf42-698094a63226.json b/25f2fc-homework-grader/Spec/290b04c0-7901-4332-bf42-698094a63226.json index 36ee5e4a..57c399da 100644 --- a/25f2fc-homework-grader/Spec/290b04c0-7901-4332-bf42-698094a63226.json +++ b/25f2fc-homework-grader/Spec/290b04c0-7901-4332-bf42-698094a63226.json @@ -9,7 +9,7 @@ "containedExamples": [ { "adoptsFrom": { - "module": "../homework-grader", + "module": "../fields", "name": "GradeField" } } @@ -20,9 +20,9 @@ "attributes": { "ref": { "name": "GradeField", - "module": "../homework-grader" + "module": "../fields" }, - "readMe": "# GradeField\n\n## Summary\n\nGradeField holds the AI's grading result for an assignment: `overallGrade` (letter A–F), `overallFeedback` (a brief markdown summary of the whole assignment), `questionPoints` (containsMany numbers, one per question, same order as the questions array), `questionFeedbacks` (containsMany markdown, one focused feedback entry per question), and a computed `overallPoints` that sums the per-question points. The grading skill fills all of these in one act-mode patch; `isGradeConsistent` (exported from `../homework-grader`) checks the arrays line up with the question count.\n\n## Usage as a Field\n\n```ts\n@field grade = contains(GradeField);\n```\n\n## Template Usage\n\n```handlebars\n<@fields.grade />\n{{@model.grade.overallGrade}} · {{@model.grade.overallPoints}} pts\n```\n", + "readMe": "# GradeField\n\n## Summary\n\nGradeField holds the AI's grading result for an assignment: `overallGrade` (letter A–F), `overallFeedback` (a brief markdown summary of the whole assignment), `questionPoints` (containsMany numbers, one per question, same order as the questions array), `questionFeedbacks` (containsMany markdown, one focused feedback entry per question), and a computed `overallPoints` that sums the per-question points. The grading skill fills all of these in one act-mode patch; `isGradeConsistent` (exported from `../fields`) checks the arrays line up with the question count.\n\n## Usage as a Field\n\n```ts\n@field grade = contains(GradeField);\n```\n\n## Template Usage\n\n```handlebars\n<@fields.grade />\n{{@model.grade.overallGrade}} · {{@model.grade.overallPoints}} pts\n```\n", "cardInfo": { "name": null, "notes": null, diff --git a/25f2fc-homework-grader/Spec/5772d471-87be-488f-a7ee-00fbf0f6e206.json b/25f2fc-homework-grader/Spec/5772d471-87be-488f-a7ee-00fbf0f6e206.json index 70f8ef6a..4513e468 100644 --- a/25f2fc-homework-grader/Spec/5772d471-87be-488f-a7ee-00fbf0f6e206.json +++ b/25f2fc-homework-grader/Spec/5772d471-87be-488f-a7ee-00fbf0f6e206.json @@ -4,7 +4,7 @@ "attributes": { "readMe": "# QuestionField\n\n## Summary\n\nQuestionField is one assignment question: `cardTitle` (the question's short name), `questionText` (markdown prompt), `answer` (the student's markdown answer, edited inline from the isolated view), `maxPoints`, and a computed `isAnswered`. The grader holds questions in a `containsMany`, and the AI awards `questionPoints[i]` / writes `questionFeedbacks[i]` against the same index.\n\n## Usage as a Field\n\n```ts\n@field questions = containsMany(QuestionField);\n```\n\n## Template Usage\n\n```handlebars\n{{#each @model.questions as |q|}}{{q.cardTitle}}{{/each}}\n```\n\nEach question renders with its own embedded and fitted views; the fitted view embeds an editable answer field.\n", "ref": { - "module": "../homework-grader", + "module": "../fields", "name": "QuestionField" }, "specType": "field", diff --git a/25f2fc-homework-grader/Spec/6c3d1f4a-8e52-4b07-9a31-0d7f5c8e2b46.json b/25f2fc-homework-grader/Spec/6c3d1f4a-8e52-4b07-9a31-0d7f5c8e2b46.json index ed76f0c5..b457afd3 100644 --- a/25f2fc-homework-grader/Spec/6c3d1f4a-8e52-4b07-9a31-0d7f5c8e2b46.json +++ b/25f2fc-homework-grader/Spec/6c3d1f4a-8e52-4b07-9a31-0d7f5c8e2b46.json @@ -2,10 +2,10 @@ "data": { "type": "card", "attributes": { - "readMe": "# LetterGradeField\n\n## Summary\n\nLetterGradeField is the letter-grade enum the grading skill is allowed to award — a `StringField` enum over A, B, C, D, E, and F. It backs `GradeField.overallGrade`, so the edit UI renders a picker rather than free text and the grading skill can only patch in a value from the scale it was given.\n\n## Import\n\n```js\nimport { LetterGradeField } from '../homework-grader';\n```\n\n## Usage as a Field\n\n```ts\n@field overallGrade = contains(LetterGradeField);\n```\n\n## Template Usage\n\n```handlebars\n{{@model.overallGrade}}\n```\n", + "readMe": "# LetterGradeField\n\n## Summary\n\nLetterGradeField is the letter-grade enum the grading skill is allowed to award — a `StringField` enum over A, B, C, D, E, and F. It backs `GradeField.overallGrade`, so the edit UI renders a picker rather than free text and the grading skill can only patch in a value from the scale it was given.\n\n## Import\n\n```js\nimport { LetterGradeField } from '../fields';\n```\n\n## Usage as a Field\n\n```ts\n@field overallGrade = contains(LetterGradeField);\n```\n\n## Template Usage\n\n```handlebars\n{{@model.overallGrade}}\n```\n", "ref": { "name": "LetterGradeField", - "module": "../homework-grader" + "module": "../fields" }, "specType": "field", "containedExamples": null, diff --git a/25f2fc-homework-grader/fields.gts b/25f2fc-homework-grader/fields.gts new file mode 100644 index 00000000..76723776 --- /dev/null +++ b/25f2fc-homework-grader/fields.gts @@ -0,0 +1,404 @@ +import { + Component, + contains, + containsMany, + field, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import BooleanField from 'https://cardstack.com/base/boolean'; +import enumField from 'https://cardstack.com/base/enum'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import NumberField from 'https://cardstack.com/base/number'; +import StringField from 'https://cardstack.com/base/string'; + +// True when a returned grade is structurally consistent with the assignment: +// a letter grade plus per-question points (and feedbacks, when present) +// matching the question count. Exported so live tests can hit it directly. +export function isGradeConsistent( + grade: + | { + overallGrade?: string | null; + questionPoints?: (number | null)[]; + questionFeedbacks?: (string | null)[]; + } + | null + | undefined, + questionCount: number, +): boolean { + if (!grade?.overallGrade) return false; + if ((grade.questionPoints?.length ?? 0) !== questionCount) return false; + let feedbacks = grade.questionFeedbacks; + if (feedbacks && feedbacks.length > 0 && feedbacks.length !== questionCount) { + return false; + } + return true; +} + +// letter grades the grading skill is allowed to award (see the skill's +// grading scale) — an enum so the edit UI is a picker, not free text +export const LetterGradeField = enumField(StringField, { + options: ['A', 'B', 'C', 'D', 'E', 'F'].map((g) => ({ value: g, label: g })), +}); + +export class GradeField extends FieldDef { + @field overallGrade = contains(LetterGradeField); + @field overallFeedback = contains(MarkdownField); + @field questionPoints = containsMany(NumberField); + // one feedback entry per question, same order/length as questionPoints + @field questionFeedbacks = containsMany(MarkdownField); + + @field overallPoints = contains(NumberField, { + computeVia: function (this: GradeField) { + return this.questionPoints.reduce((acc, num) => acc + (num || 0), 0); + }, + }); + + static edit = class Edit extends Component { + + }; + + static embedded = class Embedded extends Component { + get gradeClass() { + return `grade-${(this.args.model?.overallGrade ?? 'unknown').toUpperCase()}`; + } + + + }; +} + +export class QuestionField extends FieldDef { + static displayName = 'Question'; + + @field cardTitle = contains(StringField); + @field questionText = contains(MarkdownField); + @field answer = contains(MarkdownField); + @field maxPoints = contains(NumberField); + + @field isAnswered = contains(BooleanField, { + computeVia: function (this: QuestionField) { + return this.answer?.length > 0; + }, + }); + + static edit = class Edit extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; + + static fitted = class Fitted extends Component { + + }; +} diff --git a/25f2fc-homework-grader/homework-grader.gts b/25f2fc-homework-grader/homework-grader.gts index 2a7b3027..30ef3556 100644 --- a/25f2fc-homework-grader/homework-grader.gts +++ b/25f2fc-homework-grader/homework-grader.gts @@ -22,408 +22,11 @@ import { contains, containsMany, field, - FieldDef, linksTo, } from 'https://cardstack.com/base/card-api'; -import BooleanField from 'https://cardstack.com/base/boolean'; -import enumField from 'https://cardstack.com/base/enum'; -import MarkdownField from 'https://cardstack.com/base/markdown'; -import NumberField from 'https://cardstack.com/base/number'; import { Skill } from 'https://cardstack.com/base/skill'; -import StringField from 'https://cardstack.com/base/string'; import TextAreaField from 'https://cardstack.com/base/text-area'; - -// True when a returned grade is structurally consistent with the assignment: -// a letter grade plus per-question points (and feedbacks, when present) -// matching the question count. Exported so live tests can hit it directly. -export function isGradeConsistent( - grade: - | { - overallGrade?: string | null; - questionPoints?: (number | null)[]; - questionFeedbacks?: (string | null)[]; - } - | null - | undefined, - questionCount: number, -): boolean { - if (!grade?.overallGrade) return false; - if ((grade.questionPoints?.length ?? 0) !== questionCount) return false; - let feedbacks = grade.questionFeedbacks; - if (feedbacks && feedbacks.length > 0 && feedbacks.length !== questionCount) { - return false; - } - return true; -} - -// letter grades the grading skill is allowed to award (see the skill's -// grading scale) — an enum so the edit UI is a picker, not free text -export const LetterGradeField = enumField(StringField, { - options: ['A', 'B', 'C', 'D', 'E', 'F'].map((g) => ({ value: g, label: g })), -}); - -export class GradeField extends FieldDef { - @field overallGrade = contains(LetterGradeField); - @field overallFeedback = contains(MarkdownField); - @field questionPoints = containsMany(NumberField); - // one feedback entry per question, same order/length as questionPoints - @field questionFeedbacks = containsMany(MarkdownField); - - @field overallPoints = contains(NumberField, { - computeVia: function (this: GradeField) { - return this.questionPoints.reduce((acc, num) => acc + (num || 0), 0); - }, - }); - - static edit = class Edit extends Component { - - }; - - static embedded = class Embedded extends Component { - get gradeClass() { - return `grade-${(this.args.model?.overallGrade ?? 'unknown').toUpperCase()}`; - } - - - }; -} - -export class QuestionField extends FieldDef { - static displayName = 'Question'; - - @field cardTitle = contains(StringField); - @field questionText = contains(MarkdownField); - @field answer = contains(MarkdownField); - @field maxPoints = contains(NumberField); - - @field isAnswered = contains(BooleanField, { - computeVia: function (this: QuestionField) { - return this.answer?.length > 0; - }, - }); - - static edit = class Edit extends Component { - - }; - - static embedded = class Embedded extends Component { - - }; - - static fitted = class Fitted extends Component { - - }; -} +import { isGradeConsistent, GradeField, QuestionField } from './fields'; class HomeworkIsolated extends Component { get hasLinkedTheme(): boolean { diff --git a/41e20f-wedding-table-seating-planner/components/tsp.gts b/41e20f-wedding-table-seating-planner/components/tsp.gts index 7038617f..99772546 100644 --- a/41e20f-wedding-table-seating-planner/components/tsp.gts +++ b/41e20f-wedding-table-seating-planner/components/tsp.gts @@ -24,6 +24,26 @@ import { } from '../commands/invitation-poster-command'; import { debounce } from 'lodash-es'; import { arrayBufferToBase64 } from '../utils/encoding'; +import { + keyOf, + htmlBg, + htmlBarWidth, + htmlWorld, + htmlSeat, + htmlGhost, +} from '../utils/html-builders'; +import { + clampNum, + cloneTableGeometry, + cloneTableWithSeating, + cloneFixture, +} from '../utils/geometry'; +import { + imageDims, + withTimeout, + gridOverlay, + renderPdfToPng, +} from '../utils/async-helpers'; import type { TableSeatingPlanner } from '../table-seating-planner'; import { Guest } from '../guest'; import { Host } from '../host'; @@ -10719,195 +10739,8 @@ const TableConfig: TemplateOnlyComponent = ; -let _keySeq = 0; -const _keys = new WeakMap(); -function keyOf(obj: unknown): string { - if (!obj || typeof obj !== 'object') return ''; - let k = _keys.get(obj); - if (!k) { - k = `k${++_keySeq}`; - _keys.set(obj, k); - } - return k; -} -function htmlBg(color: string | null | undefined) { - return htmlSafe(`background:${color || '#c5a35c'}`); -} -function htmlBarWidth(pct: string) { - return htmlSafe(`width:${pct}`); -} -function htmlWorld(style: string) { - return htmlSafe(style); -} -function htmlSeat(left: string, top: string, color: string) { - return htmlSafe(`left:${left};top:${top};--seatcol:${color}`); -} -function htmlGhost(x: number, y: number) { - return htmlSafe(`left:${x}px;top:${y}px`); -} const SHAPE_VALUES = TABLE_SHAPES.map((s) => s.value); const FIXTURE_VALUES = FIXTURE_KINDS.map((k) => k.value); -function clampNum(v: unknown, min: number, max: number, def: number): number { - let n = Number(v); - if (!isFinite(n)) return def; - return Math.max(min, Math.min(max, Math.round(n))); -} -function imageDims(src: string): Promise<{ w: number; h: number }> { - return new Promise((resolve) => { - let img = new Image(); - img.onload = () => - resolve({ w: img.naturalWidth || 800, h: img.naturalHeight || 600 }); - img.onerror = () => resolve({ w: 800, h: 600 }); - img.src = src; - }); -} -function cloneTableGeometry(t: Table): Table { - return new Table({ - name: t.name, - shape: t.shape, - seatCount: t.seatCount, - seatingStyle: t.seatingStyle, - rows: t.rows, - cols: t.cols, - x: t.x, - y: t.y, - width: t.width, - height: t.height, - rotation: t.rotation, - z: t.z, - themeColor: t.themeColor, - vip: t.vip, - note: t.note, - }); -} -function cloneTableWithSeating(t: Table): Table { - let copy = cloneTableGeometry(t); - copy.seatOrder = t.seatOrder; - copy.reservedCategories = [...(t.reservedCategories ?? [])]; - copy.seatedGuests = [...((t.seatedGuests ?? []) as Guest[])]; - copy.seatSlots = [...(t.seatSlots ?? [])]; - copy.rank = t.rank; - copy.locked = t.locked; - return copy; -} -function cloneFixture(f: Fixture): Fixture { - return new Fixture({ - label: f.label, - kind: f.kind, - pattern: f.pattern, - x: f.x, - y: f.y, - width: f.width, - height: f.height, - rotation: f.rotation, - z: f.z, - color: f.color, - }); -} -function loadScriptOnce(src: string): Promise { - return new Promise((resolve, reject) => { - if (document.querySelector(`script[src='${src}']`)) return resolve(); - let s = document.createElement('script'); - s.src = src; - s.onload = () => resolve(); - s.onerror = () => reject(new Error('Could not load PDF renderer')); - document.head.appendChild(s); - }); -} -function loadImageEl(src: string): Promise { - return new Promise((resolve, reject) => { - let img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error('image decode failed')); - img.src = src; - }); -} -function withTimeout(p: Promise, ms: number, label: string): Promise { - return new Promise((resolve, reject) => { - let timer = setTimeout( - () => - reject( - new Error( - `${label} timed out after ${Math.round( - ms / 1000, - )}s — the AI service didn't respond. Check AI credits / connection and try again.`, - ), - ), - ms, - ); - p.then( - (v) => { - clearTimeout(timer); - resolve(v); - }, - (e) => { - clearTimeout(timer); - reject(e); - }, - ); - }); -} -async function gridOverlay( - dataUrl: string, - rect: { x: number; y: number; w: number; h: number }, -): Promise { - let img: HTMLImageElement; - try { - img = await loadImageEl(dataUrl); - } catch { - return dataUrl; - } - let nw = img.naturalWidth || 800; - let nh = img.naturalHeight || 600; - let MAX = 1400; - let scale = Math.min(1, MAX / Math.max(nw, nh)); - let w = Math.max(1, Math.round(nw * scale)); - let h = Math.max(1, Math.round(nh * scale)); - let canvas = document.createElement('canvas'); - canvas.width = w; - canvas.height = h; - let ctx = canvas.getContext('2d'); - if (!ctx) return dataUrl; - ctx.drawImage(img, 0, 0, w, h); - let N = 20; - ctx.strokeStyle = 'rgba(220,40,40,0.4)'; - ctx.lineWidth = Math.max(1, w / 1100); - ctx.fillStyle = 'rgba(220,40,40,0.95)'; - let fs = Math.max(10, Math.round(w / 80)); - ctx.font = `bold ${fs}px sans-serif`; - for (let i = 0; i <= N; i++) { - let px = (w * i) / N; - let py = (h * i) / N; - ctx.beginPath(); - ctx.moveTo(px, 0); - ctx.lineTo(px, h); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(0, py); - ctx.lineTo(w, py); - ctx.stroke(); - ctx.fillText(String(Math.round(rect.x + (rect.w * i) / N)), px + 3, fs + 2); - ctx.fillText(String(Math.round(rect.y + (rect.h * i) / N)), 3, py + fs + 2); - } - return canvas.toDataURL('image/png'); -} -async function renderPdfToPng(file: File): Promise { - let base = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174'; - await loadScriptOnce(`${base}/pdf.min.js`); - let pdfjs = (window as any).pdfjsLib; - if (!pdfjs) throw new Error('PDF renderer unavailable'); - pdfjs.GlobalWorkerOptions.workerSrc = `${base}/pdf.worker.min.js`; - let data = await file.arrayBuffer(); - let pdf = await pdfjs.getDocument({ data }).promise; - let page = await pdf.getPage(1); - let viewport = page.getViewport({ scale: 2 }); - let canvas = document.createElement('canvas'); - canvas.width = viewport.width; - canvas.height = viewport.height; - let context = canvas.getContext('2d'); - await page.render({ canvasContext: context, viewport }).promise; - return canvas.toDataURL('image/png'); -} export class TableSeatingPlannerFitted extends Component< typeof TableSeatingPlanner > { diff --git a/41e20f-wedding-table-seating-planner/utils/async-helpers.gts b/41e20f-wedding-table-seating-planner/utils/async-helpers.gts new file mode 100644 index 00000000..8e46d254 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/utils/async-helpers.gts @@ -0,0 +1,122 @@ +export function imageDims(src: string): Promise<{ w: number; h: number }> { + return new Promise((resolve) => { + let img = new Image(); + img.onload = () => + resolve({ w: img.naturalWidth || 800, h: img.naturalHeight || 600 }); + img.onerror = () => resolve({ w: 800, h: 600 }); + img.src = src; + }); +} + +export function loadScriptOnce(src: string): Promise { + return new Promise((resolve, reject) => { + if (document.querySelector(`script[src='${src}']`)) return resolve(); + let s = document.createElement('script'); + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error('Could not load PDF renderer')); + document.head.appendChild(s); + }); +} + +export function loadImageEl(src: string): Promise { + return new Promise((resolve, reject) => { + let img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error('image decode failed')); + img.src = src; + }); +} + +export function withTimeout( + p: Promise, + ms: number, + label: string, +): Promise { + return new Promise((resolve, reject) => { + let timer = setTimeout( + () => + reject( + new Error( + `${label} timed out after ${Math.round( + ms / 1000, + )}s — the AI service didn't respond. Check AI credits / connection and try again.`, + ), + ), + ms, + ); + p.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + +export async function gridOverlay( + dataUrl: string, + rect: { x: number; y: number; w: number; h: number }, +): Promise { + let img: HTMLImageElement; + try { + img = await loadImageEl(dataUrl); + } catch { + return dataUrl; + } + let nw = img.naturalWidth || 800; + let nh = img.naturalHeight || 600; + let MAX = 1400; + let scale = Math.min(1, MAX / Math.max(nw, nh)); + let w = Math.max(1, Math.round(nw * scale)); + let h = Math.max(1, Math.round(nh * scale)); + let canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + let ctx = canvas.getContext('2d'); + if (!ctx) return dataUrl; + ctx.drawImage(img, 0, 0, w, h); + let N = 20; + ctx.strokeStyle = 'rgba(220,40,40,0.4)'; + ctx.lineWidth = Math.max(1, w / 1100); + ctx.fillStyle = 'rgba(220,40,40,0.95)'; + let fs = Math.max(10, Math.round(w / 80)); + ctx.font = `bold ${fs}px sans-serif`; + for (let i = 0; i <= N; i++) { + let px = (w * i) / N; + let py = (h * i) / N; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, h); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, py); + ctx.lineTo(w, py); + ctx.stroke(); + ctx.fillText(String(Math.round(rect.x + (rect.w * i) / N)), px + 3, fs + 2); + ctx.fillText(String(Math.round(rect.y + (rect.h * i) / N)), 3, py + fs + 2); + } + return canvas.toDataURL('image/png'); +} + +export async function renderPdfToPng(file: File): Promise { + let base = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174'; + await loadScriptOnce(`${base}/pdf.min.js`); + let pdfjs = (window as any).pdfjsLib; + if (!pdfjs) throw new Error('PDF renderer unavailable'); + pdfjs.GlobalWorkerOptions.workerSrc = `${base}/pdf.worker.min.js`; + let data = await file.arrayBuffer(); + let pdf = await pdfjs.getDocument({ data }).promise; + let page = await pdf.getPage(1); + let viewport = page.getViewport({ scale: 2 }); + let canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + let context = canvas.getContext('2d'); + await page.render({ canvasContext: context, viewport }).promise; + return canvas.toDataURL('image/png'); +} diff --git a/41e20f-wedding-table-seating-planner/utils/geometry.gts b/41e20f-wedding-table-seating-planner/utils/geometry.gts new file mode 100644 index 00000000..3cb9bb43 --- /dev/null +++ b/41e20f-wedding-table-seating-planner/utils/geometry.gts @@ -0,0 +1,60 @@ +import { Guest } from '../guest'; +import { Table } from '../table'; +import { Fixture } from '../fixture'; + +export function clampNum( + v: unknown, + min: number, + max: number, + def: number, +): number { + let n = Number(v); + if (!isFinite(n)) return def; + return Math.max(min, Math.min(max, Math.round(n))); +} + +export function cloneTableGeometry(t: Table): Table { + return new Table({ + name: t.name, + shape: t.shape, + seatCount: t.seatCount, + seatingStyle: t.seatingStyle, + rows: t.rows, + cols: t.cols, + x: t.x, + y: t.y, + width: t.width, + height: t.height, + rotation: t.rotation, + z: t.z, + themeColor: t.themeColor, + vip: t.vip, + note: t.note, + }); +} + +export function cloneTableWithSeating(t: Table): Table { + let copy = cloneTableGeometry(t); + copy.seatOrder = t.seatOrder; + copy.reservedCategories = [...(t.reservedCategories ?? [])]; + copy.seatedGuests = [...((t.seatedGuests ?? []) as Guest[])]; + copy.seatSlots = [...(t.seatSlots ?? [])]; + copy.rank = t.rank; + copy.locked = t.locked; + return copy; +} + +export function cloneFixture(f: Fixture): Fixture { + return new Fixture({ + label: f.label, + kind: f.kind, + pattern: f.pattern, + x: f.x, + y: f.y, + width: f.width, + height: f.height, + rotation: f.rotation, + z: f.z, + color: f.color, + }); +} diff --git a/41e20f-wedding-table-seating-planner/utils/html-builders.gts b/41e20f-wedding-table-seating-planner/utils/html-builders.gts new file mode 100644 index 00000000..ac790d1a --- /dev/null +++ b/41e20f-wedding-table-seating-planner/utils/html-builders.gts @@ -0,0 +1,28 @@ +import { htmlSafe } from '@ember/template'; + +let _keySeq = 0; +const _keys = new WeakMap(); +export function keyOf(obj: unknown): string { + if (!obj || typeof obj !== 'object') return ''; + let k = _keys.get(obj); + if (!k) { + k = `k${++_keySeq}`; + _keys.set(obj, k); + } + return k; +} +export function htmlBg(color: string | null | undefined) { + return htmlSafe(`background:${color || '#c5a35c'}`); +} +export function htmlBarWidth(pct: string) { + return htmlSafe(`width:${pct}`); +} +export function htmlWorld(style: string) { + return htmlSafe(style); +} +export function htmlSeat(left: string, top: string, color: string) { + return htmlSafe(`left:${left};top:${top};--seatcol:${color}`); +} +export function htmlGhost(x: number, y: number) { + return htmlSafe(`left:${x}px;top:${y}px`); +} diff --git a/46f065-popover/popover.gts b/46f065-popover/popover.gts index a7ec52b4..8457798e 100644 --- a/46f065-popover/popover.gts +++ b/46f065-popover/popover.gts @@ -275,6 +275,36 @@ const PORTAL_FALLBACK_TOKENS = [ '--font-sans', '--font-serif', '--font-mono', + /* popover-specific knobs a host or theme may set (e.g. via Brand + * Guide custom variables) directly on the anchor's own root rather + * than through a theme scope — the scope-adoption path above already + * covers knobs set inside a linked theme's stylesheet. */ + '--bx-popover-bg', + '--bx-popover-fg', + '--bx-popover-fg-muted', + '--bx-popover-border', + '--bx-popover-accent', + '--bx-popover-dim-bg', + '--bx-popover-bg-tint', + '--bx-popover-bg-blur', + '--bx-popover-tools-bg', + '--bx-popover-tools-fg', + '--bx-popover-edit-bg', + '--bx-popover-edit-border', + '--bx-popover-radius', + '--bx-popover-shadow-raised', + '--bx-popover-shadow-elevated', + '--bx-popover-shadow-floating', + '--bx-popover-font-family', + '--bx-popover-size-compact-min-w', + '--bx-popover-size-compact-max-w', + '--bx-popover-size-compact-max-h', + '--bx-popover-size-comfortable-min-w', + '--bx-popover-size-comfortable-max-w', + '--bx-popover-size-comfortable-max-h', + '--bx-popover-size-spacious-min-w', + '--bx-popover-size-spacious-max-w', + '--bx-popover-size-spacious-max-h', ]; /** Carries the theme across the portal. diff --git a/673fb6-blackjack-cardgame-definition/blackjack.gts b/673fb6-blackjack-cardgame-definition/blackjack.gts index 67bb7858..71879927 100644 --- a/673fb6-blackjack-cardgame-definition/blackjack.gts +++ b/673fb6-blackjack-cardgame-definition/blackjack.gts @@ -5,8 +5,9 @@ import { contains, containsMany, linksTo, - FieldDef, } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; import RecordGameResultCommand from './record-game-result'; import { GameResult, @@ -22,43 +23,13 @@ import type Owner from '@ember/owner'; import { fn } from '@ember/helper'; import { on } from '@ember/modifier'; import { eq, not } from '@cardstack/boxel-ui/helpers'; -import StringField from 'https://cardstack.com/base/string'; -import NumberField from 'https://cardstack.com/base/number'; -import BooleanField from 'https://cardstack.com/base/boolean'; import ValidationSteps, { type ValidationStep } from './validation-steps'; import { codeRef, realmURL } from '@cardstack/runtime-common'; +import { PlayingCardField, StatsField, normalizeStatistics } from './fields'; // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS const here: string = import.meta.url; -class PlayingCardField extends FieldDef { - static displayName = 'Playing Card'; - @field suit = contains(StringField); - @field value = contains(StringField); - @field faceUp = contains(BooleanField); -} - -class StatsField extends FieldDef { - static displayName = 'Hand Statistics'; - @field wins = contains(NumberField); - @field losses = contains(NumberField); - @field earnings = contains(NumberField); -} - -function normalizeStatistics( - statistics?: { - wins?: number | null; - losses?: number | null; - earnings?: number | null; - } | null, -) { - return { - wins: statistics?.wins ?? 0, - losses: statistics?.losses ?? 0, - earnings: statistics?.earnings ?? 0, - }; -} - class IsolatedTemplate extends Component { // Game state @tracked gameState!: string; diff --git a/673fb6-blackjack-cardgame-definition/fields.gts b/673fb6-blackjack-cardgame-definition/fields.gts new file mode 100644 index 00000000..9640c205 --- /dev/null +++ b/673fb6-blackjack-cardgame-definition/fields.gts @@ -0,0 +1,32 @@ +import { FieldDef, field, contains } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import NumberField from 'https://cardstack.com/base/number'; +import BooleanField from 'https://cardstack.com/base/boolean'; + +export class PlayingCardField extends FieldDef { + static displayName = 'Playing Card'; + @field suit = contains(StringField); + @field value = contains(StringField); + @field faceUp = contains(BooleanField); +} + +export class StatsField extends FieldDef { + static displayName = 'Hand Statistics'; + @field wins = contains(NumberField); + @field losses = contains(NumberField); + @field earnings = contains(NumberField); +} + +export function normalizeStatistics( + statistics?: { + wins?: number | null; + losses?: number | null; + earnings?: number | null; + } | null, +) { + return { + wins: statistics?.wins ?? 0, + losses: statistics?.losses ?? 0, + earnings: statistics?.earnings ?? 0, + }; +} diff --git a/6a076d-ai-image-generator/components/generator.gts b/6a076d-ai-image-generator/components/generator.gts index a31c5539..f8b04f6b 100644 --- a/6a076d-ai-image-generator/components/generator.gts +++ b/6a076d-ai-image-generator/components/generator.gts @@ -20,32 +20,7 @@ import GeneratingOverlay from '../../components/generating-overlay'; import type { AiImage } from '../ai-image'; import { modeLabel, formatTime, type AiImageMode } from '../ai-image'; import type { AiImageGenerator } from '../ai-image-generator'; - -// 1-based version label for a history entry, used as a template helper. -function versionLabel(index: number): number { - return index + 1; -} - -// Rewrite raw command/API failures into copy that owns the failure and gives -// the person one clear next step. -function friendlyError(raw: string | null | undefined): string { - let msg = raw ?? ''; - if (/credit|payment|402/i.test(msg)) { - return "You're out of AI credits — top up to keep creating."; - } - if ( - /\b404\b|no endpoints|not a valid model|no allowed providers/i.test(msg) - ) { - return "That model isn't available right now — switch to Nano Banana and try again."; - } - if (/forbidden|permission|403/i.test(msg)) { - return "You don't have permission to save images here — open this generator in a workspace you can write to."; - } - if (/network|fetch|timeout/i.test(msg)) { - return "We couldn't reach the image service. Check your connection and try again."; - } - return msg || "That one didn't come through — try generating again."; -} +import { versionLabel, friendlyError } from '../utils/generator-helpers'; // --------------------------------------------------------------------------- // Isolated: a ChatGPT-style image chat. Each generation persists its result as diff --git a/6a076d-ai-image-generator/utils/generator-helpers.gts b/6a076d-ai-image-generator/utils/generator-helpers.gts new file mode 100644 index 00000000..8097590a --- /dev/null +++ b/6a076d-ai-image-generator/utils/generator-helpers.gts @@ -0,0 +1,25 @@ +// 1-based version label for a history entry, used as a template helper. +export function versionLabel(index: number): number { + return index + 1; +} + +// Rewrite raw command/API failures into copy that owns the failure and gives +// the person one clear next step. +export function friendlyError(raw: string | null | undefined): string { + let msg = raw ?? ''; + if (/credit|payment|402/i.test(msg)) { + return "You're out of AI credits — top up to keep creating."; + } + if ( + /\b404\b|no endpoints|not a valid model|no allowed providers/i.test(msg) + ) { + return "That model isn't available right now — switch to Nano Banana and try again."; + } + if (/forbidden|permission|403/i.test(msg)) { + return "You don't have permission to save images here — open this generator in a workspace you can write to."; + } + if (/network|fetch|timeout/i.test(msg)) { + return "We couldn't reach the image service. Check your connection and try again."; + } + return msg || "That one didn't come through — try generating again."; +} diff --git a/7af9aa-blog-app/blog-app.gts b/7af9aa-blog-app/blog-app.gts index 1965273a..42f5c96f 100644 --- a/7af9aa-blog-app/blog-app.gts +++ b/7af9aa-blog-app/blog-app.gts @@ -1,60 +1,16 @@ -import { on } from '@ember/modifier'; -import { fn, get } from '@ember/helper'; -import { action } from '@ember/object'; -import type Owner from '@ember/owner'; -import { htmlSafe } from '@ember/template'; -import GlimmerComponent from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { restartableTask } from 'ember-concurrency'; -import { - buildBlogThemeCss, - formatDatetime, - onClickOutside, - toISOString, -} from './blog-defaults'; - import { CardDef, Component, - realmURL, field, contains, linksTo, linksToMany, StringField, - type CardContext, } from 'https://cardstack.com/base/card-api'; - -import { - codeRef, - rri, - type LooseSingleCardDocument, - ResolvedCodeRef, - type Query, - searchEntryWireQueryFromQuery, - type SearchEntryWireQuery, -} from '@cardstack/runtime-common'; - -// @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS -const here: string = import.meta.url; -import { - type SortOption, - sortByCardTitleAsc, - SortMenu, -} from '../components/sort'; -import { CardList } from '../components/card-list'; -import { CardsGrid } from '../components/grid'; -import { TitleGroup, Layout, type LayoutFilter } from '../components/layout'; - -import { - BasicFitted, - BoxelButton, - FieldContainer, - ViewSelector, -} from '@cardstack/boxel-ui/components'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import { IconPlus } from '@cardstack/boxel-ui/icons'; - +import { codeRef } from '@cardstack/runtime-common'; +import { type SortOption, sortByCardTitleAsc } from '../components/sort'; +import { type LayoutFilter } from '../components/layout'; +import { BasicFitted } from '@cardstack/boxel-ui/components'; import CategoriesIcon from '@cardstack/boxel-icons/hierarchy-3'; import BlogPostIcon from '@cardstack/boxel-icons/newspaper'; import BlogAppIcon from '@cardstack/boxel-icons/notebook'; @@ -62,2282 +18,12 @@ import AuthorIcon from '@cardstack/boxel-icons/square-user'; import { BlogPost } from './blog-post'; import { Game } from './games/game'; - -// ViewSelector is used here without @items, so these mirror its own defaults. -const VIEW_OPTION_IDS = ['card', 'strip', 'grid'] as const; -type ViewOption = (typeof VIEW_OPTION_IDS)[number]; - -function isViewOption(id: string): id is ViewOption { - return VIEW_OPTION_IDS.some((option) => option === id); -} +import { IsolatedPortal } from './components/isolated-portal'; export { formatDatetime, toISOString } from './blog-defaults'; -const or = function (item1: any, item2: any) { - if (item1) { - return item1; - } else if (item2) { - return item2; - } - return; -}; - -interface CardAdminViewSignature { - Args: { - cardId: string; - context?: CardContext; - }; - Element: HTMLElement; -} -class BlogAdminData extends GlimmerComponent { - - - @tracked resource = this.args.context - ? this.args.context.getCard(this, () => this.args.cardId) - : undefined; - - formattedDate = (datetime: Date) => { - return formatDatetime(datetime, { - year: 'numeric', - month: 'numeric', - day: 'numeric', - hour12: true, - hour: 'numeric', - minute: '2-digit', - }); - }; - - get authorLabel() { - const card = this.resource?.card as any; - if (!card) return 'N/A'; - return card.formattedAuthors ?? 'N/A'; - } - - get statusModifier() { - const status = (this.resource?.card as any)?.status; - return status === 'Published' ? 'is-published' : 'is-draft'; - } - - @action togglePublished() { - const card = this.resource?.card as any; - if (!card) return; - card.published = !card.published; - (this.args.context as any)?.actions?.saveCard?.(card); - } -} - -class BlogAppTemplate extends Component { - - - @tracked private selectedView: ViewOption = 'card'; - @tracked private activeFilter: LayoutFilter; - @tracked private filters: LayoutFilter[] = []; - - constructor(owner: Owner, args: any) { - super(owner, args); - this.setFilters(); - this.activeFilter = this.filters[0]; - } - - private get context() { - return this.args.context as CardContext; - } - - private get gridClass() { - let displayName = this.activeFilter.displayName; - let gridName = - displayName === 'Blog Posts' - ? 'blog-posts-grid' - : displayName === 'Author Bios' - ? 'author-bios-grid' - : displayName === 'Categories' - ? 'categories-grid' - : ''; - return gridName ? `bordered-items ${gridName}` : ''; - } - - private setFilters() { - let makeQuery = (codeRef: ResolvedCodeRef) => ({ - filter: { type: codeRef }, - }); - - this.filters = - this.args.model.filters?.map((filter) => { - if (!filter.query && filter.cardRef) { - return { - ...filter, - query: makeQuery(filter.cardRef), - }; - } - return filter; - }) ?? []; - } - - private get selectedSort() { - if (!this.activeFilter.sortOptions?.length) { - return undefined; - } - return this.activeFilter.selectedSort ?? this.activeFilter.sortOptions[0]; - } - - private get showAdminData() { - return this.activeFilter.showAdminData && this.selectedView === 'card'; - } - - private get realms() { - return [this.args.model[realmURL]!]; - } - - private get realmHrefs() { - return this.realms.map((url) => url.href); - } - - private get query() { - return { - ...this.activeFilter.query, - sort: this.selectedSort?.sort ?? sortByCardTitleAsc, - }; - } - - @action private onChangeView(id: string) { - if (isViewOption(id)) { - this.selectedView = id; - } - } - - @action private onSort(option: SortOption) { - this.activeFilter = { ...this.activeFilter, selectedSort: option }; - } - - @action private onFilterChange(filter: LayoutFilter) { - this.activeFilter = filter; - } - - @action private createNew() { - this.createCard.perform(); - } - - private createCard = restartableTask(async () => { - // the filter's cardRef is the source of truth; fall back to whichever - // shape the query filter carries (type filters here, on-filters if a - // custom query is supplied) - let filter = this.activeFilter?.query?.filter as - | { type?: ResolvedCodeRef; on?: ResolvedCodeRef } - | undefined; - let ref = this.activeFilter?.cardRef ?? filter?.type ?? filter?.on; - - if (!ref) { - throw new Error('Missing card ref'); - } - let currentRealm = this.realms[0]; - let doc: LooseSingleCardDocument = { - data: { - type: 'card', - meta: { - adoptsFrom: ref, - }, - }, - }; - await this.args.createCard?.(ref, currentRealm, { - realmURL: currentRealm, - doc, - }); - }); -} - -type LatestFilter = 'all' | 'latest' | 'news' | 'new-york' | 'tech'; - -// Reader-facing view: NYT-inspired magazine layout, lists BlogPosts via search. -class BlogSiteView extends Component { - get latestSearchQuery(): SearchEntryWireQuery { - return { - ...searchEntryWireQueryFromQuery(this.latestQuery), - realms: this.realmHrefs, - }; - } - get picksSearchQuery(): SearchEntryWireQuery { - return { - ...searchEntryWireQueryFromQuery(this.picksQuery), - realms: this.realmHrefs, - }; - } - @tracked activeFilter: LatestFilter = 'all'; - @tracked dragOverSlot: string | null = null; - - private waitForCardLoad(resource: any): Promise { - // bounded poll: resolves on load/error, gives up after 5s, and stops - // outright if the component is torn down mid-wait - return new Promise((resolve) => { - const started = Date.now(); - const check = () => { - if ( - resource.card || - resource.cardError || - Date.now() - started > 5000 || - this.isDestroying || - this.isDestroyed - ) { - resolve(); - } else { - setTimeout(check, 50); - } - }; - check(); - }); - } - - private async resolveCardFromUrl(url: string): Promise { - const context = (this.args as any).context; - if (!context?.getCard) return null; - const resource = context.getCard(this, () => url); - await this.waitForCardLoad(resource); - return (resource?.card as BlogPost) ?? null; - } - - @tracked draggingFeaturedIndex: number | null = null; - @tracked dragOverFeaturedIndex: number | null = null; - @tracked draggingLead = false; - - private hasInternalFeaturedDrag(event: DragEvent): boolean { - const dt = event.dataTransfer; - if (!dt) return false; - const types = Array.from(dt.types ?? []); - return types.includes('application/x-featured-index'); - } - - private hasInternalLeadDrag(event: DragEvent): boolean { - const dt = event.dataTransfer; - if (!dt) return false; - const types = Array.from(dt.types ?? []); - return types.includes('application/x-lead-slot'); - } - - @action publishLead() { - let lead = this.args.model.lead as any; - if (!lead) return; - lead.published = true; - (this.args.context as any)?.actions?.saveCard?.(lead); - } - - @action onLeadDragStart(event: Event) { - const ev = event as DragEvent; - this.draggingLead = true; - if (ev.dataTransfer) { - ev.dataTransfer.setData('application/x-lead-slot', '1'); - ev.dataTransfer.setData('text/plain', 'lead'); - ev.dataTransfer.effectAllowed = 'move'; - } - } - - @action onLeadDragEnd() { - this.draggingLead = false; - } - - private swapLeadWithFeatured(featuredIdx: number) { - const model = this.args.model as any; - const featured = [...((model.featured as BlogPost[]) ?? [])]; - if (featuredIdx < 0 || featuredIdx >= featured.length) return; - const promoted = featured[featuredIdx]; - const oldLead = model.lead as BlogPost | undefined; - if (oldLead) { - featured[featuredIdx] = oldLead; - } else { - // No prior lead — pull the card out of featured entirely. - featured.splice(featuredIdx, 1); - } - model.lead = promoted; - model.featured = featured; - const actions = (this.args as any).context?.actions; - actions?.saveCard?.(this.args.model); - } - - @action onFeaturedDragStart(index: number, event: DragEvent) { - this.draggingFeaturedIndex = index; - if (event.dataTransfer) { - event.dataTransfer.setData('application/x-featured-index', String(index)); - event.dataTransfer.setData('text/plain', `featured:${index}`); - event.dataTransfer.effectAllowed = 'move'; - } - } - - @action onFeaturedDragOver(index: number, event: DragEvent) { - // Intercept in-list reorders AND lead-to-featured swaps. External - // drags (URL from the library drawer) fall through to the - // .featured-list's 'featured-append' handler. - if ( - !this.hasInternalFeaturedDrag(event) && - !this.hasInternalLeadDrag(event) - ) { - return; - } - event.preventDefault(); - event.stopPropagation(); - this.dragOverFeaturedIndex = index; - if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; - } - - @action onFeaturedDragLeave(index: number) { - if (this.dragOverFeaturedIndex === index) { - this.dragOverFeaturedIndex = null; - } - } - - @action onFeaturedDrop(index: number, event: DragEvent) { - // Lead → featured swap - if (this.hasInternalLeadDrag(event)) { - event.preventDefault(); - event.stopPropagation(); - this.draggingLead = false; - this.dragOverFeaturedIndex = null; - this.swapLeadWithFeatured(index); - return; - } - if (!this.hasInternalFeaturedDrag(event)) { - // External drop — let .featured-list's handler turn it into an append. - return; - } - event.preventDefault(); - event.stopPropagation(); - const raw = - event.dataTransfer?.getData('application/x-featured-index') ?? ''; - const sourceIdx = parseInt(raw, 10); - this.draggingFeaturedIndex = null; - this.dragOverFeaturedIndex = null; - if (Number.isNaN(sourceIdx) || sourceIdx === index) return; - const model = this.args.model as any; - const arr = [...((model.featured as BlogPost[]) ?? [])]; - if (sourceIdx < 0 || sourceIdx >= arr.length) return; - const [moving] = arr.splice(sourceIdx, 1); - const insertAt = Math.min(Math.max(0, index), arr.length); - arr.splice(insertAt, 0, moving); - model.featured = arr; - const actions = (this.args as any).context?.actions; - actions?.saveCard?.(this.args.model); - } - - @action onFeaturedDragEnd() { - this.draggingFeaturedIndex = null; - this.dragOverFeaturedIndex = null; - } - - @action onSlotDragOver(slotId: string, event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - this.dragOverSlot = slotId; - if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; - } - - @action onSlotDragLeave(slotId: string) { - if (this.dragOverSlot === slotId) { - this.dragOverSlot = null; - } - } - - @action onSlotDrop(slotId: string, event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - this.dragOverSlot = null; - // Featured → lead swap - if (slotId === 'lead' && this.hasInternalFeaturedDrag(event)) { - const sourceIdx = parseInt( - event.dataTransfer?.getData('application/x-featured-index') ?? '-1', - 10, - ); - this.draggingFeaturedIndex = null; - this.dragOverFeaturedIndex = null; - if (!Number.isNaN(sourceIdx)) { - this.swapLeadWithFeatured(sourceIdx); - } - return; - } - // An internal lead drag dropped on its own slot — no-op, just clean up. - if (this.hasInternalLeadDrag(event)) { - this.draggingLead = false; - return; - } - const dt = event.dataTransfer; - const url = - dt?.getData('text/uri-list')?.split('\n')[0]?.trim() || - dt?.getData('text/plain') || - ''; - if (!url || url.startsWith('lead') || url.startsWith('featured:')) return; - this.assignSlot(slotId, url); - } - - @action async assignSlot(slotId: string, url: string) { - const card = await this.resolveCardFromUrl(url); - if (!card) return; - const model = this.args.model as any; - if (slotId === 'lead') { - model.lead = card; - } else if (slotId === 'featured-append') { - const next = [...((model.featured as BlogPost[]) ?? [])]; - const url = (card as any).id; - if (!next.some((p) => (p as any).id === url)) { - next.push(card); - model.featured = next; - } - } else if (slotId === 'games-append') { - const next = [...((model.games as Game[]) ?? [])]; - // Avoid duplicates — if this game is already linked, do nothing. - const url = (card as any).id; - if (!next.some((g) => (g as any).id === url)) { - next.push(card as unknown as Game); - model.games = next; - } - } - const actions = (this.args as any).context?.actions; - actions?.saveCard?.(this.args.model); - } - - get hasFeatured(): boolean { - return Boolean((this.args.model as any).featured?.length); - } - - get hasGames(): boolean { - return Boolean((this.args.model as any).games?.length); - } - - get realmHrefs(): string[] { - const u = this.args.model[realmURL]; - return u ? [u.href] : []; - } - - get query() { - const on = codeRef(here, './blog-post', 'BlogPost'); - return { - filter: { on, eq: { published: true } }, - sort: [{ on, by: 'publishDate', direction: 'desc' as const }], - }; - } - - get picksQuery(): Query { - const on = codeRef(here, './blog-post', 'BlogPost'); - return { - filter: { - on, - eq: { published: true, 'categories.slug': 'writers-pick' }, - }, - sort: [{ on, by: 'publishDate', direction: 'desc' as const }], - }; - } - - get latestQuery(): Query { - const on = codeRef(here, './blog-post', 'BlogPost'); - const categorySlug = - this.activeFilter === 'news' - ? 'news' - : this.activeFilter === 'new-york' - ? 'new-york' - : this.activeFilter === 'tech' - ? 'future-tech' - : undefined; - return { - filter: { - on, - eq: categorySlug - ? { published: true, 'categories.slug': categorySlug } - : { published: true }, - }, - sort: [{ on, by: 'publishDate', direction: 'desc' }], - }; - } - - @action setFilter(f: LatestFilter) { - this.activeFilter = f; - } - - get todayLabel(): string { - return new Date() - .toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - }) - .toUpperCase(); - } - - -} - -// Portal wrapper: collapsible left drawer + main content (site or admin). -class IsolatedPortal extends Component { - get hasLinkedTheme(): boolean { - return Boolean((this.args.model as any)?.cardInfo?.theme); - } - - get themeSearchQuery(): SearchEntryWireQuery { - return { - ...searchEntryWireQueryFromQuery(this.themeQuery), - realms: this.realmHrefs, - }; - } - get libraryPostsSearchQuery(): SearchEntryWireQuery { - return { - ...searchEntryWireQueryFromQuery(this.libraryPostsQuery), - realms: this.realmHrefs, - }; - } - @tracked viewMode: 'site' | 'admin' = 'site'; - @tracked drawerOpen = false; - @tracked searchQuery = ''; - @tracked private _pendingThemeUrl: string | undefined = undefined; - - @action toggleViewMode() { - this.viewMode = this.viewMode === 'site' ? 'admin' : 'site'; - } - - @action toggleDrawer() { - this.drawerOpen = !this.drawerOpen; - } - - @action closeDrawer() { - this.drawerOpen = false; - } - - @action maybeCloseDrawer() { - if (this.drawerOpen) this.drawerOpen = false; - } - - @action onSearchInput(event: Event) { - this.searchQuery = (event.target as HTMLInputElement).value; - } - - get themeQuery(): Query { - return { - filter: { - type: { - module: rri('https://cardstack.com/base/style-reference'), - name: 'default', - }, - }, - }; - } - - private normalizeUrl(u: string | null | undefined): string { - if (!u) return ''; - return String(u) - .replace(/\.json$/, '') - .replace(/\/$/, ''); - } - - get currentThemeUrl(): string { - if (this._pendingThemeUrl !== undefined) { - return this.normalizeUrl(this._pendingThemeUrl); - } - const linked = (this.args.model as any)?.cardInfo?.theme; - return this.normalizeUrl(linked?.id ?? linked?.url ?? ''); - } - - isThemeSelected = (url: string | null | undefined): boolean => { - return this.normalizeUrl(url) === this.currentThemeUrl; - }; - - @action setTheme(url: string | null) { - const model = this.args.model as any; - const ctx = (this.args as any).context; - this._pendingThemeUrl = url ?? ''; - if (!url) { - if (model.cardInfo) model.cardInfo.theme = null; - ctx?.actions?.saveCard?.(this.args.model); - return; - } - const resource = ctx?.getCard?.(this, () => url); - if (!resource) return; - const started = Date.now(); - const poll = setInterval(() => { - if (this.isDestroying || this.isDestroyed) { - clearInterval(poll); - } else if (resource.card) { - clearInterval(poll); - if (model.cardInfo) model.cardInfo.theme = resource.card; - ctx?.actions?.saveCard?.(this.args.model); - } else if (Date.now() - started > 5000) { - clearInterval(poll); - } - }, 60); - } - - @action onThemeRadioChange(url: string | null, event: Event) { - if ((event.target as HTMLInputElement).checked) { - this.setTheme(url); - } - } - - @action onLibraryDragStart(url: string, event: DragEvent) { - if (event.dataTransfer) { - event.dataTransfer.setData('text/uri-list', url); - event.dataTransfer.setData('text/plain', url); - event.dataTransfer.effectAllowed = 'copyMove'; - } - } - - get realmHrefs(): string[] { - const u = this.args.model[realmURL]; - return u ? [u.href] : []; - } - - get libraryPostsQuery(): Query { - const on = codeRef(here, './blog-post', 'BlogPost'); - const sort = [{ on, by: 'publishDate', direction: 'desc' as const }]; - const q = this.searchQuery.trim(); - if (!q) { - return { filter: { type: on }, sort }; - } - return { - filter: { - every: [ - { type: on }, - { - any: [{ matches: q }, { contains: { cardTitle: q } }], - }, - ], - }, - sort, - }; - } - - get themeStyle() { - return htmlSafe( - buildBlogThemeCss((this.args.model as any)?.cardInfo?.theme), - ); - } - - -} +// @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS +const here: string = import.meta.url; // TODO: BlogApp should extend AppCard // Using type CardDef instead of AppCard from catalog because of diff --git a/7af9aa-blog-app/components/admin-template.gts b/7af9aa-blog-app/components/admin-template.gts new file mode 100644 index 00000000..5a17667f --- /dev/null +++ b/7af9aa-blog-app/components/admin-template.gts @@ -0,0 +1,284 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask } from 'ember-concurrency'; +import { + Component, + realmURL, + type CardContext, +} from 'https://cardstack.com/base/card-api'; +import { + type LooseSingleCardDocument, + type ResolvedCodeRef, +} from '@cardstack/runtime-common'; +import { + type SortOption, + sortByCardTitleAsc, + SortMenu, +} from '../../components/sort'; +import { CardList } from '../../components/card-list'; +import { CardsGrid } from '../../components/grid'; +import { TitleGroup, Layout, type LayoutFilter } from '../../components/layout'; +import { BoxelButton, ViewSelector } from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { IconPlus } from '@cardstack/boxel-ui/icons'; +import { BlogAdminData } from './admin-view'; +import type { BlogApp } from '../blog-app'; +import type { BlogPost } from '../blog-post'; + +// ViewSelector is used here without @items, so these mirror its own defaults. +const VIEW_OPTION_IDS = ['card', 'strip', 'grid'] as const; +type ViewOption = (typeof VIEW_OPTION_IDS)[number]; + +function isViewOption(id: string): id is ViewOption { + return VIEW_OPTION_IDS.some((option) => option === id); +} + +const or = function (item1: any, item2: any) { + if (item1) { + return item1; + } else if (item2) { + return item2; + } + return; +}; + +export class BlogAppTemplate extends Component { + + + @tracked private selectedView: ViewOption = 'card'; + @tracked private activeFilter: LayoutFilter; + @tracked private filters: LayoutFilter[] = []; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.setFilters(); + this.activeFilter = this.filters[0]; + } + + private get context() { + return this.args.context as CardContext; + } + + private get gridClass() { + let displayName = this.activeFilter.displayName; + let gridName = + displayName === 'Blog Posts' + ? 'blog-posts-grid' + : displayName === 'Author Bios' + ? 'author-bios-grid' + : displayName === 'Categories' + ? 'categories-grid' + : ''; + return gridName ? `bordered-items ${gridName}` : ''; + } + + private setFilters() { + let makeQuery = (codeRef: ResolvedCodeRef) => ({ + filter: { type: codeRef }, + }); + + this.filters = + this.args.model.filters?.map((filter) => { + if (!filter.query && filter.cardRef) { + return { + ...filter, + query: makeQuery(filter.cardRef), + }; + } + return filter; + }) ?? []; + } + + private get selectedSort() { + if (!this.activeFilter.sortOptions?.length) { + return undefined; + } + return this.activeFilter.selectedSort ?? this.activeFilter.sortOptions[0]; + } + + private get showAdminData() { + return this.activeFilter.showAdminData && this.selectedView === 'card'; + } + + private get realms() { + return [this.args.model[realmURL]!]; + } + + private get realmHrefs() { + return this.realms.map((url) => url.href); + } + + private get query() { + return { + ...this.activeFilter.query, + sort: this.selectedSort?.sort ?? sortByCardTitleAsc, + }; + } + + @action private onChangeView(id: string) { + if (isViewOption(id)) { + this.selectedView = id; + } + } + + @action private onSort(option: SortOption) { + this.activeFilter = { ...this.activeFilter, selectedSort: option }; + } + + @action private onFilterChange(filter: LayoutFilter) { + this.activeFilter = filter; + } + + @action private createNew() { + this.createCard.perform(); + } + + private createCard = restartableTask(async () => { + // the filter's cardRef is the source of truth; fall back to whichever + // shape the query filter carries (type filters here, on-filters if a + // custom query is supplied) + let filter = this.activeFilter?.query?.filter as + | { type?: ResolvedCodeRef; on?: ResolvedCodeRef } + | undefined; + let ref = this.activeFilter?.cardRef ?? filter?.type ?? filter?.on; + + if (!ref) { + throw new Error('Missing card ref'); + } + let currentRealm = this.realms[0]; + let doc: LooseSingleCardDocument = { + data: { + type: 'card', + meta: { + adoptsFrom: ref, + }, + }, + }; + await this.args.createCard?.(ref, currentRealm, { + realmURL: currentRealm, + doc, + }); + }); +} diff --git a/7af9aa-blog-app/components/admin-view.gts b/7af9aa-blog-app/components/admin-view.gts new file mode 100644 index 00000000..5a8e11ea --- /dev/null +++ b/7af9aa-blog-app/components/admin-view.gts @@ -0,0 +1,191 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { type CardContext } from 'https://cardstack.com/base/card-api'; +import { FieldContainer } from '@cardstack/boxel-ui/components'; +import { formatDatetime, toISOString } from '../blog-defaults'; +import type { BlogPost } from '../blog-post'; + +interface CardAdminViewSignature { + Args: { + cardId: string; + context?: CardContext; + }; + Element: HTMLElement; +} +export class BlogAdminData extends GlimmerComponent { + + + @tracked resource = this.args.context + ? this.args.context.getCard(this, () => this.args.cardId) + : undefined; + + formattedDate = (datetime: Date) => { + return formatDatetime(datetime, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour12: true, + hour: 'numeric', + minute: '2-digit', + }); + }; + + get authorLabel() { + const card = this.resource?.card as any; + if (!card) return 'N/A'; + return card.formattedAuthors ?? 'N/A'; + } + + get statusModifier() { + const status = (this.resource?.card as any)?.status; + return status === 'Published' ? 'is-published' : 'is-draft'; + } + + @action togglePublished() { + const card = this.resource?.card as any; + if (!card) return; + card.published = !card.published; + (this.args.context as any)?.actions?.saveCard?.(card); + } +} diff --git a/7af9aa-blog-app/components/isolated-portal.gts b/7af9aa-blog-app/components/isolated-portal.gts new file mode 100644 index 00000000..25fafe6b --- /dev/null +++ b/7af9aa-blog-app/components/isolated-portal.gts @@ -0,0 +1,597 @@ +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import { tracked } from '@glimmer/tracking'; +import { Component, realmURL } from 'https://cardstack.com/base/card-api'; +import { + codeRef, + rri, + type Query, + searchEntryWireQueryFromQuery, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; +import { buildBlogThemeCss, onClickOutside } from '../blog-defaults'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { BlogSiteView } from './site-view'; +import { BlogAppTemplate } from './admin-template'; +import type { BlogApp } from '../blog-app'; + +// @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS +const here: string = import.meta.url; + +export class IsolatedPortal extends Component { + get hasLinkedTheme(): boolean { + return Boolean((this.args.model as any)?.cardInfo?.theme); + } + + get themeSearchQuery(): SearchEntryWireQuery { + return { + ...searchEntryWireQueryFromQuery(this.themeQuery), + realms: this.realmHrefs, + }; + } + get libraryPostsSearchQuery(): SearchEntryWireQuery { + return { + ...searchEntryWireQueryFromQuery(this.libraryPostsQuery), + realms: this.realmHrefs, + }; + } + @tracked viewMode: 'site' | 'admin' = 'site'; + @tracked drawerOpen = false; + @tracked searchQuery = ''; + @tracked private _pendingThemeUrl: string | undefined = undefined; + + @action toggleViewMode() { + this.viewMode = this.viewMode === 'site' ? 'admin' : 'site'; + } + + @action toggleDrawer() { + this.drawerOpen = !this.drawerOpen; + } + + @action closeDrawer() { + this.drawerOpen = false; + } + + @action maybeCloseDrawer() { + if (this.drawerOpen) this.drawerOpen = false; + } + + @action onSearchInput(event: Event) { + this.searchQuery = (event.target as HTMLInputElement).value; + } + + get themeQuery(): Query { + return { + filter: { + type: { + module: rri('https://cardstack.com/base/style-reference'), + name: 'default', + }, + }, + }; + } + + private normalizeUrl(u: string | null | undefined): string { + if (!u) return ''; + return String(u) + .replace(/\.json$/, '') + .replace(/\/$/, ''); + } + + get currentThemeUrl(): string { + if (this._pendingThemeUrl !== undefined) { + return this.normalizeUrl(this._pendingThemeUrl); + } + const linked = (this.args.model as any)?.cardInfo?.theme; + return this.normalizeUrl(linked?.id ?? linked?.url ?? ''); + } + + isThemeSelected = (url: string | null | undefined): boolean => { + return this.normalizeUrl(url) === this.currentThemeUrl; + }; + + @action setTheme(url: string | null) { + const model = this.args.model as any; + const ctx = (this.args as any).context; + this._pendingThemeUrl = url ?? ''; + if (!url) { + if (model.cardInfo) model.cardInfo.theme = null; + ctx?.actions?.saveCard?.(this.args.model); + return; + } + const resource = ctx?.getCard?.(this, () => url); + if (!resource) return; + const started = Date.now(); + const poll = setInterval(() => { + if (this.isDestroying || this.isDestroyed) { + clearInterval(poll); + } else if (resource.card) { + clearInterval(poll); + if (model.cardInfo) model.cardInfo.theme = resource.card; + ctx?.actions?.saveCard?.(this.args.model); + } else if (Date.now() - started > 5000) { + clearInterval(poll); + } + }, 60); + } + + @action onThemeRadioChange(url: string | null, event: Event) { + if ((event.target as HTMLInputElement).checked) { + this.setTheme(url); + } + } + + @action onLibraryDragStart(url: string, event: DragEvent) { + if (event.dataTransfer) { + event.dataTransfer.setData('text/uri-list', url); + event.dataTransfer.setData('text/plain', url); + event.dataTransfer.effectAllowed = 'copyMove'; + } + } + + get realmHrefs(): string[] { + const u = this.args.model[realmURL]; + return u ? [u.href] : []; + } + + get libraryPostsQuery(): Query { + const on = codeRef(here, '../blog-post', 'BlogPost'); + const sort = [{ on, by: 'publishDate', direction: 'desc' as const }]; + const q = this.searchQuery.trim(); + if (!q) { + return { filter: { type: on }, sort }; + } + return { + filter: { + every: [ + { type: on }, + { + any: [{ matches: q }, { contains: { cardTitle: q } }], + }, + ], + }, + sort, + }; + } + + get themeStyle() { + return htmlSafe( + buildBlogThemeCss((this.args.model as any)?.cardInfo?.theme), + ); + } + + +} diff --git a/7af9aa-blog-app/components/site-view.gts b/7af9aa-blog-app/components/site-view.gts new file mode 100644 index 00000000..ff8f03dc --- /dev/null +++ b/7af9aa-blog-app/components/site-view.gts @@ -0,0 +1,1276 @@ +import { on } from '@ember/modifier'; +import { fn, get } from '@ember/helper'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { Component, realmURL } from 'https://cardstack.com/base/card-api'; +import { + codeRef, + type Query, + searchEntryWireQueryFromQuery, + type SearchEntryWireQuery, +} from '@cardstack/runtime-common'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { BlogPost } from '../blog-post'; +import { Game } from '../games/game'; +import type { BlogApp } from '../blog-app'; + +// @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS +const here: string = import.meta.url; + +type LatestFilter = 'all' | 'latest' | 'news' | 'new-york' | 'tech'; + +// Reader-facing view: NYT-inspired magazine layout, lists BlogPosts via search. +export class BlogSiteView extends Component { + get latestSearchQuery(): SearchEntryWireQuery { + return { + ...searchEntryWireQueryFromQuery(this.latestQuery), + realms: this.realmHrefs, + }; + } + get picksSearchQuery(): SearchEntryWireQuery { + return { + ...searchEntryWireQueryFromQuery(this.picksQuery), + realms: this.realmHrefs, + }; + } + @tracked activeFilter: LatestFilter = 'all'; + @tracked dragOverSlot: string | null = null; + + private waitForCardLoad(resource: any): Promise { + // bounded poll: resolves on load/error, gives up after 5s, and stops + // outright if the component is torn down mid-wait + return new Promise((resolve) => { + const started = Date.now(); + const check = () => { + if ( + resource.card || + resource.cardError || + Date.now() - started > 5000 || + this.isDestroying || + this.isDestroyed + ) { + resolve(); + } else { + setTimeout(check, 50); + } + }; + check(); + }); + } + + private async resolveCardFromUrl(url: string): Promise { + const context = (this.args as any).context; + if (!context?.getCard) return null; + const resource = context.getCard(this, () => url); + await this.waitForCardLoad(resource); + return (resource?.card as BlogPost) ?? null; + } + + @tracked draggingFeaturedIndex: number | null = null; + @tracked dragOverFeaturedIndex: number | null = null; + @tracked draggingLead = false; + + private hasInternalFeaturedDrag(event: DragEvent): boolean { + const dt = event.dataTransfer; + if (!dt) return false; + const types = Array.from(dt.types ?? []); + return types.includes('application/x-featured-index'); + } + + private hasInternalLeadDrag(event: DragEvent): boolean { + const dt = event.dataTransfer; + if (!dt) return false; + const types = Array.from(dt.types ?? []); + return types.includes('application/x-lead-slot'); + } + + @action publishLead() { + let lead = this.args.model.lead as any; + if (!lead) return; + lead.published = true; + (this.args.context as any)?.actions?.saveCard?.(lead); + } + + @action onLeadDragStart(event: Event) { + const ev = event as DragEvent; + this.draggingLead = true; + if (ev.dataTransfer) { + ev.dataTransfer.setData('application/x-lead-slot', '1'); + ev.dataTransfer.setData('text/plain', 'lead'); + ev.dataTransfer.effectAllowed = 'move'; + } + } + + @action onLeadDragEnd() { + this.draggingLead = false; + } + + private swapLeadWithFeatured(featuredIdx: number) { + const model = this.args.model as any; + const featured = [...((model.featured as BlogPost[]) ?? [])]; + if (featuredIdx < 0 || featuredIdx >= featured.length) return; + const promoted = featured[featuredIdx]; + const oldLead = model.lead as BlogPost | undefined; + if (oldLead) { + featured[featuredIdx] = oldLead; + } else { + // No prior lead — pull the card out of featured entirely. + featured.splice(featuredIdx, 1); + } + model.lead = promoted; + model.featured = featured; + const actions = (this.args as any).context?.actions; + actions?.saveCard?.(this.args.model); + } + + @action onFeaturedDragStart(index: number, event: DragEvent) { + this.draggingFeaturedIndex = index; + if (event.dataTransfer) { + event.dataTransfer.setData('application/x-featured-index', String(index)); + event.dataTransfer.setData('text/plain', `featured:${index}`); + event.dataTransfer.effectAllowed = 'move'; + } + } + + @action onFeaturedDragOver(index: number, event: DragEvent) { + // Intercept in-list reorders AND lead-to-featured swaps. External + // drags (URL from the library drawer) fall through to the + // .featured-list's 'featured-append' handler. + if ( + !this.hasInternalFeaturedDrag(event) && + !this.hasInternalLeadDrag(event) + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.dragOverFeaturedIndex = index; + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + } + + @action onFeaturedDragLeave(index: number) { + if (this.dragOverFeaturedIndex === index) { + this.dragOverFeaturedIndex = null; + } + } + + @action onFeaturedDrop(index: number, event: DragEvent) { + // Lead → featured swap + if (this.hasInternalLeadDrag(event)) { + event.preventDefault(); + event.stopPropagation(); + this.draggingLead = false; + this.dragOverFeaturedIndex = null; + this.swapLeadWithFeatured(index); + return; + } + if (!this.hasInternalFeaturedDrag(event)) { + // External drop — let .featured-list's handler turn it into an append. + return; + } + event.preventDefault(); + event.stopPropagation(); + const raw = + event.dataTransfer?.getData('application/x-featured-index') ?? ''; + const sourceIdx = parseInt(raw, 10); + this.draggingFeaturedIndex = null; + this.dragOverFeaturedIndex = null; + if (Number.isNaN(sourceIdx) || sourceIdx === index) return; + const model = this.args.model as any; + const arr = [...((model.featured as BlogPost[]) ?? [])]; + if (sourceIdx < 0 || sourceIdx >= arr.length) return; + const [moving] = arr.splice(sourceIdx, 1); + const insertAt = Math.min(Math.max(0, index), arr.length); + arr.splice(insertAt, 0, moving); + model.featured = arr; + const actions = (this.args as any).context?.actions; + actions?.saveCard?.(this.args.model); + } + + @action onFeaturedDragEnd() { + this.draggingFeaturedIndex = null; + this.dragOverFeaturedIndex = null; + } + + @action onSlotDragOver(slotId: string, event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + this.dragOverSlot = slotId; + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + } + + @action onSlotDragLeave(slotId: string) { + if (this.dragOverSlot === slotId) { + this.dragOverSlot = null; + } + } + + @action onSlotDrop(slotId: string, event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + this.dragOverSlot = null; + // Featured → lead swap + if (slotId === 'lead' && this.hasInternalFeaturedDrag(event)) { + const sourceIdx = parseInt( + event.dataTransfer?.getData('application/x-featured-index') ?? '-1', + 10, + ); + this.draggingFeaturedIndex = null; + this.dragOverFeaturedIndex = null; + if (!Number.isNaN(sourceIdx)) { + this.swapLeadWithFeatured(sourceIdx); + } + return; + } + // An internal lead drag dropped on its own slot — no-op, just clean up. + if (this.hasInternalLeadDrag(event)) { + this.draggingLead = false; + return; + } + const dt = event.dataTransfer; + const url = + dt?.getData('text/uri-list')?.split('\n')[0]?.trim() || + dt?.getData('text/plain') || + ''; + if (!url || url.startsWith('lead') || url.startsWith('featured:')) return; + this.assignSlot(slotId, url); + } + + @action async assignSlot(slotId: string, url: string) { + const card = await this.resolveCardFromUrl(url); + if (!card) return; + const model = this.args.model as any; + if (slotId === 'lead') { + model.lead = card; + } else if (slotId === 'featured-append') { + const next = [...((model.featured as BlogPost[]) ?? [])]; + const url = (card as any).id; + if (!next.some((p) => (p as any).id === url)) { + next.push(card); + model.featured = next; + } + } else if (slotId === 'games-append') { + const next = [...((model.games as Game[]) ?? [])]; + // Avoid duplicates — if this game is already linked, do nothing. + const url = (card as any).id; + if (!next.some((g) => (g as any).id === url)) { + next.push(card as unknown as Game); + model.games = next; + } + } + const actions = (this.args as any).context?.actions; + actions?.saveCard?.(this.args.model); + } + + get hasFeatured(): boolean { + return Boolean((this.args.model as any).featured?.length); + } + + get hasGames(): boolean { + return Boolean((this.args.model as any).games?.length); + } + + get realmHrefs(): string[] { + const u = this.args.model[realmURL]; + return u ? [u.href] : []; + } + + get query() { + const on = codeRef(here, '../blog-post', 'BlogPost'); + return { + filter: { on, eq: { published: true } }, + sort: [{ on, by: 'publishDate', direction: 'desc' as const }], + }; + } + + get picksQuery(): Query { + const on = codeRef(here, '../blog-post', 'BlogPost'); + return { + filter: { + on, + eq: { published: true, 'categories.slug': 'writers-pick' }, + }, + sort: [{ on, by: 'publishDate', direction: 'desc' as const }], + }; + } + + get latestQuery(): Query { + const on = codeRef(here, '../blog-post', 'BlogPost'); + const categorySlug = + this.activeFilter === 'news' + ? 'news' + : this.activeFilter === 'new-york' + ? 'new-york' + : this.activeFilter === 'tech' + ? 'future-tech' + : undefined; + return { + filter: { + on, + eq: categorySlug + ? { published: true, 'categories.slug': categorySlug } + : { published: true }, + }, + sort: [{ on, by: 'publishDate', direction: 'desc' }], + }; + } + + @action setFilter(f: LatestFilter) { + this.activeFilter = f; + } + + get todayLabel(): string { + return new Date() + .toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + .toUpperCase(); + } + + +} diff --git a/boxel-surface-demo/mortgage-surface-demo/MortgageSurfaceDemo/index.json b/boxel-surface-demo/mortgage-surface-demo/MortgageSurfaceDemo/index.json deleted file mode 100644 index 16b23fb7..00000000 --- a/boxel-surface-demo/mortgage-surface-demo/MortgageSurfaceDemo/index.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "MortgageSurfaceDemo", - "module": "../mortgage-surface-demo" - } - }, - "type": "card", - "attributes": { - "cardInfo": { - "name": "Mortgage Calculator", - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "currency": { - "code": "MYR" - }, - "homePrice": 2750000, - "taxPerMonth": 1650, - "loanTermYears": 30, - "hoaFeesPerMonth": 0, - "insurancePerMonth": 330, - "downPaymentPercentage": 20, - "interestRatePercentage": 5.2 - }, - "relationships": { - "cardInfo.theme": { - "links": { - "self": null - } - }, - "cardInfo.cardThumbnail": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/boxel-surface-demo/mortgage-surface-demo/components/donut-chart.gts b/boxel-surface-demo/mortgage-surface-demo/components/donut-chart.gts deleted file mode 100644 index 7cfd9411..00000000 --- a/boxel-surface-demo/mortgage-surface-demo/components/donut-chart.gts +++ /dev/null @@ -1,229 +0,0 @@ -import GlimmerComponent from '@glimmer/component'; -import { on } from '@ember/modifier'; -import { fn } from '@ember/helper'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import { formatCurrency, svgPieStartAngle } from './utils'; - -/* ---------- DONUT CHART (monthly breakdown) ---------- */ - -interface DonutSectionSignature { - Element: SVGGElement; - Args: { - fill: string; - size: number; - value: number | undefined; - total: number; - startAngle: number; - }; -} - -class DonutSection extends GlimmerComponent { - get halfWidth() { - return this.args.size / 2; - } - get radius() { - return this.halfWidth; - } - get startX() { - return ( - this.halfWidth + - this.radius * Math.cos(this.args.startAngle * (Math.PI / 180)) - ); - } - get startY() { - return ( - this.halfWidth + - this.radius * Math.sin(this.args.startAngle * (Math.PI / 180)) - ); - } - get angle() { - if (!this.args.total) return 0; - const angle = ((this.args.value || 0) / this.args.total) * 360; - return angle < 359.99 ? angle : 359.99; - } - get endAngle() { - return this.angle + this.args.startAngle; - } - get largeArcFlag() { - return this.angle > 180 ? 1 : 0; - } - get sweepFlag() { - return this.args.startAngle < this.endAngle ? 1 : 0; - } - get endX() { - return ( - this.halfWidth + this.radius * Math.cos(this.endAngle * (Math.PI / 180)) - ); - } - get endY() { - return ( - this.halfWidth + this.radius * Math.sin(this.endAngle * (Math.PI / 180)) - ); - } - -} - -export interface DonutSectionData { - key?: string; - class?: string; - color: string; - value: number | undefined; - label: string; - percent: number | undefined; -} - -interface DonutChartSignature { - Element: HTMLDivElement; - Args: { - data: DonutSectionData[]; - size: number; - currencyCode?: string; - onHover?: (key: string | null) => void; - }; -} - -export class DonutChart extends GlimmerComponent { - get viewBox() { - const { size } = this.args; - return `0 0 ${size} ${size}`; - } - get total() { - const { data } = this.args; - return data.reduce((sum, item) => sum + (item.value || 0), 0); - } - get center() { - return this.args.size / 2; - } - get holeRadius() { - return this.args.size * 0.36; - } - get centerLabelY() { - return this.center - 10; - } - get centerValueY() { - return this.center + 12; - } - noopHover = (_key: string | null): void => {}; - get hoverHandler() { - return this.args.onHover ?? this.noopHover; - } - -} diff --git a/boxel-surface-demo/mortgage-surface-demo/components/line-chart.gts b/boxel-surface-demo/mortgage-surface-demo/components/line-chart.gts deleted file mode 100644 index f429590e..00000000 --- a/boxel-surface-demo/mortgage-surface-demo/components/line-chart.gts +++ /dev/null @@ -1,475 +0,0 @@ -import GlimmerComponent from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; -import { on } from '@ember/modifier'; -import { fn, concat } from '@ember/helper'; -import { htmlSafe } from '@ember/template'; -import { formatCurrency, formatCurrencyShort } from './utils'; - -/* ---------- LINE CHART (amortization over time) ---------- */ - -export interface AmortPoint { - year: number; - principalPaid: number; - interestPaid: number; - totalPaid: number; - balance: number; -} - -type AmortKey = 'principalPaid' | 'interestPaid' | 'totalPaid' | 'balance'; - -interface LineSeriesDef { - key: AmortKey; - label: string; - color: string; - dashed: boolean; -} - -interface LineChartSignature { - Element: SVGElement; - Args: { - data: AmortPoint[]; - width?: number; - height?: number; - currencyCode?: string; - }; -} - -export class LineChart extends GlimmerComponent { - @tracked hoverYear: number | null = null; - @tracked enabledKeys: Set = new Set([ - 'totalPaid', - 'principalPaid', - 'interestPaid', - 'balance', - ]); - - margin = { top: 24, right: 24, bottom: 36, left: 64 }; - - seriesDefs: LineSeriesDef[] = [ - { - key: 'totalPaid', - label: 'Cumulative Paid', - color: 'var(--mc-teal, #007272)', - dashed: false, - }, - { - key: 'principalPaid', - label: 'Cumulative Principal', - color: 'var(--mc-green, #059669)', - dashed: false, - }, - { - key: 'interestPaid', - label: 'Cumulative Interest', - color: 'var(--chart-5, #ef4444)', - dashed: false, - }, - { - key: 'balance', - label: 'Remaining Balance', - color: 'var(--chart-2, #589BFF)', - dashed: true, - }, - ]; - - get width() { - return this.args.width ?? 640; - } - get height() { - return this.args.height ?? 320; - } - get innerWidth() { - return this.width - this.margin.left - this.margin.right; - } - get innerHeight() { - return this.height - this.margin.top - this.margin.bottom; - } - get viewBox() { - return `0 0 ${this.width} ${this.height}`; - } - get xMaxPx() { - return this.margin.left + this.innerWidth; - } - get innerBottomY() { - return this.margin.top + this.innerHeight; - } - get xMax() { - return this.args.data?.length - ? this.args.data[this.args.data.length - 1].year - : 1; - } - get yMax() { - if (!this.args.data?.length) return 1; - let max = 0; - for (let point of this.args.data) { - for (let s of this.seriesDefs) { - if (!this.enabledKeys.has(s.key)) continue; - const v = point[s.key]; - if (v > max) max = v; - } - } - return max || 1; - } - - xFor = (year: number): number => { - return this.margin.left + (year / (this.xMax || 1)) * this.innerWidth; - }; - - yFor = (value: number): number => { - return ( - this.margin.top + - this.innerHeight - - (value / this.yMax) * this.innerHeight - ); - }; - - get series() { - return this.seriesDefs.map((s) => { - const points = this.args.data - .map((d) => `${this.xFor(d.year)},${this.yFor(d[s.key])}`) - .join(' '); - return { - ...s, - points, - dashArray: s.dashed ? '6,4' : '0', - enabled: this.enabledKeys.has(s.key), - }; - }); - } - - get yTicks() { - const ticks: { y: number; label: string }[] = []; - const steps = 4; - const cc = this.args.currencyCode ?? 'USD'; - for (let i = 0; i <= steps; i++) { - const v = (this.yMax / steps) * i; - ticks.push({ - y: this.yFor(v), - label: formatCurrencyShort(v, cc), - }); - } - return ticks; - } - - get xTicks() { - if (!this.args.data?.length) return []; - const stride = Math.max(1, Math.ceil(this.xMax / 6)); - const out: { year: number; x: number }[] = []; - for (let y = 0; y <= this.xMax; y += stride) { - out.push({ year: y, x: this.xFor(y) }); - } - if (out.length && out[out.length - 1].year !== this.xMax) { - out.push({ year: this.xMax, x: this.xFor(this.xMax) }); - } - return out; - } - - get hoverPoint(): AmortPoint | null { - if (this.hoverYear === null) return null; - return this.args.data.find((d) => d.year === this.hoverYear) ?? null; - } - - get hoverX() { - return this.hoverPoint ? this.xFor(this.hoverPoint.year) : 0; - } - - get hoverDots() { - if (!this.hoverPoint) return []; - const p = this.hoverPoint; - return this.seriesDefs - .filter((s) => this.enabledKeys.has(s.key)) - .map((s) => ({ - cx: this.xFor(p.year), - cy: this.yFor(p[s.key]), - color: s.color, - key: s.key, - })); - } - - get tooltipRows() { - if (!this.hoverPoint) return []; - const p = this.hoverPoint; - return this.seriesDefs - .filter((s) => this.enabledKeys.has(s.key)) - .map((s) => ({ - label: s.label, - color: s.color, - value: p[s.key], - key: s.key, - })); - } - - isEnabled = (key: AmortKey): boolean => { - return this.enabledKeys.has(key); - }; - - toggleClass = (key: AmortKey): string => { - return this.enabledKeys.has(key) ? 'lc-toggle active' : 'lc-toggle'; - }; - - @action - handleMouseMove(evt: Event) { - const target = evt.currentTarget as SVGSVGElement; - const rect = target.getBoundingClientRect(); - const scaleX = this.width / rect.width; - const relX = ((evt as MouseEvent).clientX - rect.left) * scaleX; - if (relX < this.margin.left || relX > this.xMaxPx) { - this.hoverYear = null; - return; - } - const ratio = (relX - this.margin.left) / this.innerWidth; - const yr = Math.round(ratio * this.xMax); - this.hoverYear = Math.max(0, Math.min(this.xMax, yr)); - } - - @action - handleMouseLeave() { - this.hoverYear = null; - } - - @action - toggleSeries(key: AmortKey) { - const next = new Set(this.enabledKeys); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - this.enabledKeys = next; - } - - -} diff --git a/boxel-surface-demo/mortgage-surface-demo/components/utils.gts b/boxel-surface-demo/mortgage-surface-demo/components/utils.gts deleted file mode 100644 index 46a5b6dc..00000000 --- a/boxel-surface-demo/mortgage-surface-demo/components/utils.gts +++ /dev/null @@ -1,51 +0,0 @@ -export function formatCurrency(val: number | undefined, cc = 'USD') { - if (val === undefined || !Number.isFinite(val)) return ''; - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: cc, - }).format(val); -} - -export function formatCurrencyShort(val: number | undefined, cc = 'USD') { - if (val === undefined || !Number.isFinite(val)) return ''; - - const symbol = - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: cc, - maximumFractionDigits: 0, - }) - .formatToParts(0) - .find((p) => p.type === 'currency')?.value ?? cc; - - const sep = symbol.length > 1 ? ' ' : ''; - - if (Math.abs(val) >= 1_000_000) - return `${symbol}${sep}${(val / 1_000_000).toFixed(1)}M`; - - if (Math.abs(val) >= 1_000) - return `${symbol}${sep}${Math.round(val / 1_000)}k`; - - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: cc, - maximumFractionDigits: 0, - }).format(val); -} - -export function svgPieStartAngle({ - data, - index, - total, - start = 0, -}: { - data: { value?: number }[]; - index: number; - total: number; - start?: number; -}) { - return data.slice(0, index).reduce((sum, item) => { - const angle = ((item.value || 0) / total) * 360 || 0; - return sum + angle; - }, start); -} diff --git a/boxel-surface-demo/mortgage-surface-demo/mortgage-surface-demo.gts b/boxel-surface-demo/mortgage-surface-demo/mortgage-surface-demo.gts deleted file mode 100644 index fe85093c..00000000 --- a/boxel-surface-demo/mortgage-surface-demo/mortgage-surface-demo.gts +++ /dev/null @@ -1,2216 +0,0 @@ -import NumberField from 'https://cardstack.com/base/number'; -import CurrencyField from 'https://cardstack.com/base/currency'; -import { - CardDef, - field, - contains, - Component, -} from 'https://cardstack.com/base/card-api'; -// @ts-ignore — esm.run module has no type defs -import { currencyCodeSymbolMapping } from 'https://esm.run/currency-code-symbol-map'; -import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; -import { on } from '@ember/modifier'; -import { fn, concat } from '@ember/helper'; -import { htmlSafe } from '@ember/template'; -import { eq } from '@cardstack/boxel-ui/helpers'; -import ChevronDown from '@cardstack/boxel-icons/chevron-down'; -import OneShotLlmRequestCommand from '@cardstack/boxel-host/commands/one-shot-llm-request'; -import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; -import { - Environment, - Layout, - Pane, - Form, - FormField, - FormSection, - NumberCell, - Grid, - Row, - Cell, - Run, - Lift, - type LiftKind, -} from '../../boxel-surface/src/index'; -import { LineChart } from './components/line-chart'; -import type { AmortPoint } from './components/line-chart'; -import { DonutChart } from './components/donut-chart'; -import type { DonutSectionData } from './components/donut-chart'; -import { formatCurrency } from './components/utils'; - -export class MortgageSurfaceDemo extends CardDef { - static displayName = 'Mortgage Calculator — Surfaces'; - static prefersWideFormat = true; - - @field currency = contains(CurrencyField); - @field homePrice = contains(NumberField); - @field downPaymentPercentage = contains(NumberField); - @field loanTermYears = contains(NumberField); - @field interestRatePercentage = contains(NumberField); - @field taxPerMonth = contains(NumberField); - @field insurancePerMonth = contains(NumberField); - @field hoaFeesPerMonth = contains(NumberField); - - @field downPayment = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.homePrice ?? 0) * ((this.downPaymentPercentage ?? 0) / 100); - }, - }); - @field loanAmount = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.homePrice ?? 0) - (this.downPayment ?? 0); - }, - }); - @field numberOfPayments = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.loanTermYears ?? 0) * 12; - }, - }); - @field monthlyInterestRate = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.interestRatePercentage ?? 0) / 100 / 12; - }, - }); - @field monthlyMortgagePayment = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - const r = this.monthlyInterestRate ?? 0; - const n = this.numberOfPayments ?? 0; - const L = this.loanAmount ?? 0; - if (!L || !n) return 0; - if (r === 0) return L / n; - return L * ((r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1)); - }, - }); - @field monthlyTotal = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return ( - (this.monthlyMortgagePayment ?? 0) + - (this.taxPerMonth ?? 0) + - (this.insurancePerMonth ?? 0) + - (this.hoaFeesPerMonth ?? 0) - ); - }, - }); - @field lifetimeMortgagePayment = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.monthlyMortgagePayment ?? 0) * (this.numberOfPayments ?? 0); - }, - }); - @field lifetimeInterest = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.lifetimeMortgagePayment ?? 0) - (this.loanAmount ?? 0); - }, - }); - @field lifetimeTaxes = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.taxPerMonth ?? 0) * (this.numberOfPayments ?? 0); - }, - }); - @field lifetimeInsurance = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.insurancePerMonth ?? 0) * (this.numberOfPayments ?? 0); - }, - }); - @field lifetimeHoaFees = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return (this.hoaFeesPerMonth ?? 0) * (this.numberOfPayments ?? 0); - }, - }); - @field lifetimeTotal = contains(NumberField, { - computeVia(this: MortgageSurfaceDemo) { - return ( - (this.lifetimeMortgagePayment ?? 0) + - (this.lifetimeTaxes ?? 0) + - (this.lifetimeInsurance ?? 0) + - (this.lifetimeHoaFees ?? 0) - ); - }, - }); -} - -type CategoryKey = 'pi' | 'tax' | 'insurance' | 'hoa'; -type StatKey = - | 'loanAmount' - | 'downPayment' - | 'monthlyPayment' - | 'totalInterest'; - -const FENCED_JSON_RE = new RegExp('```(?:json)?\\s*([\\s\\S]*?)```'); - -class MortgageSurfaceDemoIsolated extends Component< - typeof MortgageSurfaceDemo -> { - @tracked activeTab: 'breakdown' | 'timeline' = 'timeline'; - - @tracked currencyLiftKind: LiftKind | null = null; - - // Cross-highlight state for the monthly breakdown. The three views - // (Grid row, DonutChart segment, legend row) all call setHover with - // the same CategoryKey, and each binds data-active to drive its own - // CSS. Mirrors the "same-cell-three-hosts" coordination pattern — - // lifted tracked state on the parent, multiple views subscribe. - @tracked hoveredCategory: CategoryKey | null = null; - - // Stat-card preview lift — one Lift instance, anchor switches with - // `hoveredStat`. Hover-driven, so it never traps focus. - @tracked hoveredStat: StatKey | null = null; - @tracked chartToolsOpen = false; // attached + tools - @tracked scenarioOpen = false; // plane + scrim + modal - @tracked scenarioRate = 0; - @tracked scenarioTerm = 0; - - // AI Quick Fill — extracts mortgage data from a pasted listing. - @tracked quickFillText = ''; - @tracked quickFillStatus: 'idle' | 'loading' | 'success' | 'error' = 'idle'; - @tracked quickFillError = ''; - @tracked lastSnapshot: Record | null = null; - @tracked quickFillOpen = true; - @tracked debugRaw = ''; - - get headerStyle() { - return htmlSafe( - 'background: linear-gradient(135deg, rgba(255,200,60,0.18) 0%, rgba(60,120,40,0.32) 35%, rgba(8,38,18,0.72) 70%, rgba(3,18,8,0.90) 100%), url(https://images.pexels.com/photos/31737842/pexels-photo-31737842.jpeg?auto=compress&cs=tinysrgb&w=1400) center / cover no-repeat', - ); - } - - @action updateQuickFillText(evt: Event) { - this.quickFillText = (evt.target as HTMLTextAreaElement).value; - } - - @action toggleQuickFill() { - this.quickFillOpen = !this.quickFillOpen; - } - - @action async runQuickFill() { - const commandContext = this.args.context?.commandContext; - if (!commandContext) { - this.quickFillStatus = 'error'; - this.quickFillError = - 'Command context unavailable — open this card in the full view rather than embedded.'; - return; - } - const text = this.quickFillText.trim(); - if (!text) { - this.quickFillStatus = 'error'; - this.quickFillError = 'Paste a Zillow URL or listing description first.'; - return; - } - this.quickFillStatus = 'loading'; - this.quickFillError = ''; - this.debugRaw = ''; - try { - const cc = this.currencyCode; - const systemPrompt = `You extract structured mortgage data from real-estate listings. -INPUT: a URL or free-text description. -OUTPUT: ONE JSON object only — no prose, no markdown fences, no commentary. - -The user's selected currency is ${cc}. Return ALL monetary values converted to ${cc} using your best knowledge of current exchange rates. Do not return USD values if the currency is not USD. - -Required keys (all numeric except sourceNotes): -{ - "homePrice": number, // listing price in ${cc} - "downPaymentPercentage": number, // default 20 - "loanTermYears": number, // default 30 - "interestRatePercentage": number, // typical rate for the property's country, or 6.8 if unknown - "taxPerMonth": number, // monthly property tax in ${cc} — use listing data or estimate - "insurancePerMonth": number, // monthly home insurance in ${cc} — estimate if not given - "hoaFeesPerMonth": number, // monthly HOA in ${cc}, 0 if detached/no HOA - "sourceNotes": string // one short sentence noting currency used and any conversions applied -} - -Use reasonable defaults whenever a value is missing. Never return null.`; - const command = new OneShotLlmRequestCommand(commandContext); - const result = await command.execute({ - systemPrompt, - userPrompt: text, - llmModel: 'anthropic/claude-haiku-4.5', - }); - const raw = - (result as any)?.output ?? (result as any)?.attributes?.output ?? ''; - this.debugRaw = String(raw).slice(0, 800); - const parsed = this.parseLlmJson(String(raw)); - if (!parsed) { - throw new Error( - `Couldn't parse JSON from the response. Raw output shown below.`, - ); - } - this.lastSnapshot = this.snapshot(); - this.applyValues(parsed); - try { - const { model } = this.args; - await new SaveCardCommand(commandContext).execute({ - card: model as any, - }); - } catch (saveErr) { - console.warn('[QuickFill] save failed (values still applied)', saveErr); - } - this.quickFillStatus = 'success'; - } catch (err: any) { - this.quickFillStatus = 'error'; - this.quickFillError = err?.message ?? 'Unknown error'; - } - } - - @action undoQuickFill() { - if (!this.lastSnapshot) return; - this.applyValues(this.lastSnapshot); - this.lastSnapshot = null; - this.quickFillStatus = 'idle'; - } - - parseLlmJson(raw: string): Record | null { - if (!raw) return null; - let text = raw.trim(); - const fenced = text.match(FENCED_JSON_RE); - if (fenced) text = fenced[1].trim(); - try { - return this.coerce(JSON.parse(text)); - } catch { - const start = text.indexOf('{'); - const end = text.lastIndexOf('}'); - if (start >= 0 && end > start) { - try { - return this.coerce(JSON.parse(text.slice(start, end + 1))); - } catch { - return null; - } - } - return null; - } - } - - coerce(obj: any): Record | null { - if (!obj || typeof obj !== 'object') return null; - const keys = [ - 'homePrice', - 'downPaymentPercentage', - 'loanTermYears', - 'interestRatePercentage', - 'taxPerMonth', - 'insurancePerMonth', - 'hoaFeesPerMonth', - ]; - const out: Record = {}; - for (const k of keys) { - const v = Number(obj[k]); - if (Number.isFinite(v)) out[k] = v; - } - return Object.keys(out).length ? out : null; - } - - snapshot(): Record { - const { model } = this.args; - return { - homePrice: model.homePrice ?? 0, - downPaymentPercentage: model.downPaymentPercentage ?? 0, - loanTermYears: model.loanTermYears ?? 0, - interestRatePercentage: model.interestRatePercentage ?? 0, - taxPerMonth: model.taxPerMonth ?? 0, - insurancePerMonth: model.insurancePerMonth ?? 0, - hoaFeesPerMonth: model.hoaFeesPerMonth ?? 0, - }; - } - - applyValues(values: Record) { - const { model } = this.args; - if (values.homePrice !== undefined) model.homePrice = values.homePrice; - if (values.downPaymentPercentage !== undefined) - model.downPaymentPercentage = values.downPaymentPercentage; - if (values.loanTermYears !== undefined) - model.loanTermYears = values.loanTermYears; - if (values.interestRatePercentage !== undefined) - model.interestRatePercentage = values.interestRatePercentage; - if (values.taxPerMonth !== undefined) - model.taxPerMonth = values.taxPerMonth; - if (values.insurancePerMonth !== undefined) - model.insurancePerMonth = values.insurancePerMonth; - if (values.hoaFeesPerMonth !== undefined) - model.hoaFeesPerMonth = values.hoaFeesPerMonth; - } - - get currencyCode(): string { - return this.args.model.currency?.code ?? 'USD'; - } - - get currencySymbol(): string { - return (currencyCodeSymbolMapping as Record)[ - this.currencyCode - ]; - } - - @action openCurrencyLift() { - this.currencyLiftKind = 'edit'; - } - @action dismissCurrencyLift() { - this.currencyLiftKind = null; - } - @action escalateCurrencyLift(next: LiftKind) { - this.currencyLiftKind = next; - } - - currencyEscalation: LiftKind[] = ['details', 'edit']; - - // Stat-card hover preview — mouseenter sets the key, mouseleave clears. - @action openStatPreview(key: StatKey) { - this.hoveredStat = key; - } - @action closeStatPreview() { - this.hoveredStat = null; - } - - isStatActive = (k: StatKey): boolean => this.hoveredStat === k; - - get statAnchorSelector(): string { - return `[data-lift-anchor=mcs-stat-${this.hoveredStat}]`; - } - - get downPaymentRatio(): number { - return this.args.model.downPaymentPercentage ?? 0; - } - - get interestShareOfTotalMortgage(): number { - const total = this.args.model.lifetimeMortgagePayment ?? 0; - if (!total) return 0; - return Math.round(((this.args.model.lifetimeInterest ?? 0) / total) * 100); - } - - // Chart tools menu (tools lift). - @action openChartTools() { - this.chartToolsOpen = true; - } - @action closeChartTools() { - this.chartToolsOpen = false; - } - @action exportCsv() { - const cc = this.currencyCode; - const rows: (string | number)[][] = [ - ['Category', 'Monthly', 'Lifetime'], - [ - 'Mortgage (P&I)', - this.args.model.monthlyMortgagePayment ?? 0, - this.args.model.lifetimeMortgagePayment ?? 0, - ], - [ - 'Property tax', - this.args.model.taxPerMonth ?? 0, - this.args.model.lifetimeTaxes ?? 0, - ], - [ - 'Home insurance', - this.args.model.insurancePerMonth ?? 0, - this.args.model.lifetimeInsurance ?? 0, - ], - [ - 'HOA fees', - this.args.model.hoaFeesPerMonth ?? 0, - this.args.model.lifetimeHoaFees ?? 0, - ], - [ - 'Total', - this.args.model.monthlyTotal ?? 0, - this.args.model.lifetimeTotal ?? 0, - ], - ]; - const escape = (cell: string | number): string => { - const s = String(cell); - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; - }; - const csv = rows.map((r) => r.map(escape).join(',')).join('\n'); - const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `mortgage-breakdown-${cc}.csv`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - this.closeChartTools(); - } - @action printSummary() { - // Triggers the browser's native print dialog; users can save as PDF. - // The whole document prints — for a per-card print sheet you'd - // render a print-only stylesheet inside this component. - this.closeChartTools(); - window.print(); - } - - // Scenario modal (plane + scrim). - @action openScenario() { - this.scenarioRate = this.args.model.interestRatePercentage ?? 0; - this.scenarioTerm = this.args.model.loanTermYears ?? 0; - this.scenarioOpen = true; - } - @action closeScenario() { - this.scenarioOpen = false; - } - @action setScenarioRate(val: string) { - const v = parseFloat(val); - if (Number.isFinite(v)) this.scenarioRate = v; - } - @action setScenarioTerm(val: string) { - const v = parseFloat(val); - if (Number.isFinite(v)) this.scenarioTerm = v; - } - @action applyScenario() { - this.args.model.interestRatePercentage = this.scenarioRate; - this.args.model.loanTermYears = this.scenarioTerm; - this.scenarioOpen = false; - } - - get scenarioMonthly(): number { - const L = this.args.model.loanAmount ?? 0; - const n = this.scenarioTerm * 12; - const r = this.scenarioRate / 100 / 12; - if (!L || !n) return 0; - if (r === 0) return L / n; - return L * ((r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1)); - } - - get amortization(): AmortPoint[] { - const { model } = this.args; - const years = model.loanTermYears ?? 0; - const monthlyPayment = model.monthlyMortgagePayment ?? 0; - const monthlyRate = model.monthlyInterestRate ?? 0; - let balance = model.loanAmount ?? 0; - const out: AmortPoint[] = [ - { year: 0, principalPaid: 0, interestPaid: 0, totalPaid: 0, balance }, - ]; - if (!years || !monthlyPayment || balance <= 0) return out; - let cumPrincipal = 0; - let cumInterest = 0; - for (let y = 1; y <= years; y++) { - for (let m = 0; m < 12 && balance > 0; m++) { - const interest = balance * monthlyRate; - let principal = monthlyPayment - interest; - if (principal > balance) principal = balance; - balance -= principal; - cumPrincipal += principal; - cumInterest += interest; - } - out.push({ - year: y, - principalPaid: cumPrincipal, - interestPaid: cumInterest, - totalPaid: cumPrincipal + cumInterest, - balance: Math.max(0, balance), - }); - } - return out; - } - - get chartData(): (DonutSectionData & { key: CategoryKey })[] { - const { model } = this.args; - const total = model.monthlyTotal || 1; - return [ - { - key: 'pi', - value: model.monthlyMortgagePayment, - color: 'var(--mc-green, #059669)', - label: 'Principal & Interest', - percent: Math.round( - ((model.monthlyMortgagePayment ?? 0) / total) * 100, - ), - }, - { - key: 'tax', - value: model.taxPerMonth, - color: 'var(--chart-2, #589BFF)', - label: 'Property Taxes', - percent: Math.round(((model.taxPerMonth ?? 0) / total) * 100), - }, - { - key: 'insurance', - value: model.insurancePerMonth, - color: 'var(--chart-5, #ef4444)', - label: 'Home Insurance', - percent: Math.round(((model.insurancePerMonth ?? 0) / total) * 100), - }, - { - key: 'hoa', - value: model.hoaFeesPerMonth, - color: 'var(--chart-4, #f59e0b)', - label: 'HOA Fees', - percent: Math.round(((model.hoaFeesPerMonth ?? 0) / total) * 100), - }, - ]; - } - - @action setTab(tab: 'breakdown' | 'timeline') { - this.activeTab = tab; - } - - // Cross-highlight helper — three views read the same hovered state. - isActive = (k: CategoryKey): boolean => this.hoveredCategory === k; - - @action setHoverCategory(k: string | null) { - this.hoveredCategory = k as CategoryKey | null; - } - - @action setHomePrice(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.homePrice = n; - } - - @action setDownPaymentPercentage(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.downPaymentPercentage = n; - } - - @action setLoanTermYears(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.loanTermYears = n; - } - - @action setInterestRatePercentage(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.interestRatePercentage = n; - } - - @action setTaxPerMonth(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.taxPerMonth = n; - } - - @action setInsurancePerMonth(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.insurancePerMonth = n; - } - - @action setHoaFeesPerMonth(val: string) { - const n = parseFloat(val); - if (Number.isFinite(n)) this.args.model.hoaFeesPerMonth = n; - } - - -} - -MortgageSurfaceDemo.isolated = MortgageSurfaceDemoIsolated; diff --git a/boxel-surface/LICENSE b/boxel-surface/LICENSE deleted file mode 100644 index 1ae8f0e6..00000000 --- a/boxel-surface/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 Cardstack - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/boxel-surface/README.md b/boxel-surface/README.md deleted file mode 100644 index 8241c2d0..00000000 --- a/boxel-surface/README.md +++ /dev/null @@ -1,186 +0,0 @@ -
- -# boxel-surface - -**Headless interaction engine for typed-block UIs.** -A library of primitives — `Environment`, `Layout`, `Grid`, `Row`, `Cell`, -`Run`, `Unit`, `Canvas`, `Scene`, `Frame`, `Plane`, `Outline`, `Pane`, -`Scroll`, `Flow`, `Lift` — plus a runtime that -bakes the right keyboard, focus, selection, lifted-edit, and inspection -patterns into reusable contracts. Built on Glimmer / Ember. - -> Surfaces is to UI semantics what react-aria is to accessibility. - -
- -```gts -import { Cell, Environment, Layout } from '../boxel-surface/src'; - - -``` - -The engine handles traversal, browser focus, selection scoping, lift/input -ownership, dismissal on Esc / outside-click, ARIA roles, and projection-driven -adornments. Package bindings such as `surfaceGridBinding` and -`surfaceCanvasBinding` add sheet and canvas-specific gestures on top. The host -owns *what* a cell or object does on commit; the engine owns *how* it gets -there. - ---- - -## Install - -`boxel-surface` now lives inside the catalog realm at -`catalog/contents/boxel-surface/`. Consumers import it by relative path -from anywhere else in the same realm (e.g. `'../boxel-surface/src'`) — no -npm install step required. - -`@cardstack/boxel-ui` is an optional peer for default thimble visuals. - -## What you get - -| Layer | What's in it | -|------------------|---------------------------------------------------------------------------------------------------------------| -| **Foundations** | 14 surface kinds — `Environment`, `Layout`, `Pane`, `Frame`, `Plane`, `Canvas`, `Scene`, `Grid`, `Row`, `Cell`, `Run`, `Unit`, `Scroll`, `Flow`, `Outline` | -| **Lift** | Anchored floating surface for focused interaction. Four kinds: `details`, `preview`, `edit`, `tools`. | -| **Form** | `Form`, `FormField`, `FormSection`, `FormTabs` / `FormTab`, `FormWizard` / `FormStep`, `FormAlert`, plus canonical cells `TextCell`, `EmailCell`, `NumberCell`, `SwitchCell`. Density / layout / columns cascade from `Form` to children. | -| **Cues** | `CueLabel`, `CueDescription`, `CueStatus` — accessibility-wired support UI. | -| **Engine** | `SurfaceRuntime`, scoped subscriptions, viewport state, focus ladder compatibility, lift edges, contract negotiation, surface rules, projection-driven decals. | -| **Modifiers** | `surfaceRoot`, `surfaceNode`, `surfaceGridBinding`, `surfaceCanvasBinding`, `surfaceSceneBinding`, `surfaceDecalLayer`, `surfaceInlineEdit`, `multiUnit`, `surfaceLiftBinding`, `portal`. | - -## Current Runtime Shape - -The maintained path is runtime-driven. `Environment` creates the runtime and -foundation/package surfaces register semantic participants. Package bindings own -normal interaction behavior: - -- `Grid @preset="sheet"` owns cell selection, column/header projection, keyboard - movement, edit handoff, and Escape cleanup. -- `surfaceCanvasBinding` owns object and edge selection, object move/resize, - marquee, nudge, duplicate/delete, connection callbacks, snap, auto-pan, and - transformed-canvas reveal hooks. -- `surfaceSceneBinding` is the scene-facing binding over the same object - machinery; scene hosts still own camera/orbit behavior. -- `SurfaceRuntime` exposes scoped `subscribeSelection`, `subscribeTopology`, - `subscribeInput`, and `subscribeViewport` channels. Use the broad - `subscribe()` channel only for compatibility or whole-runtime diagnostics. - -Structural page chrome should not become selected product state by accident. Use -`@posture` / `@inspect` for authoring posture, and use low-level runtime policy -overrides such as `@runtimePointer="preview-only"` only when a structural surface -needs to render context while leaving selection to descendants. - -## Two dialects, one engine - -**Portable** — expanded markup, explicit `@space` / `@coord` / `@schema`, -local CSS + tokens. Lossless and copy-pasteable. - -**Adaptive** — concise, pattern-driven markup. The runtime fills in -defaults (traversal, ARIA, responsive, Cue decals, Place candidates) -from contracts and rules. - -Both dialects resolve to the same surface tree. - -## The workbench - -Every primitive ships with a live exhibit that paints coordinate decals -over real surfaces, lets you walk the runtime projection with Tab, and -shows lift escalation chaining through `details` → `edit` → `tools`. -The workbench is maintained alongside the original surfaces source and -is not bundled into the catalog realm — open it from the upstream -surfaces development environment when you need it. - -The workbench has six tiers: - -| Tier | What it is | -|--------------|------------------------------------------------------------------------------------------------------| -| **Showcase** | Same Cell, three hosts · Cross-host drag · Lift escalation · Agent dashboard | -| **Concepts** | One-page reference for each v3 word — Surface, Cue, Coordinate, Posture & Inspect, Lift, Place, Adorn, Trail, Pattern, Traversal Set, Rule Matching | -| **Lessons** | Mental model + the 10-lesson Build track | -| **Apps** | Spreadsheet (boxel) · Document outline (notion) · Canvas board (figma) · Canvas flow (figma) · Space (3D) · Music library (spotify) · Storefront composer (shopify) · Form lab (form chrome) | -| **Reference**| Storybook-style component panels — args, schema, examples, code. Every entry is sourced from the live TypeScript signatures. | -| **Matrix** | 14 × 14 pairwise composition catalog | - -## Vocabulary, in one paragraph - -A **Surface** is a meaningful unit of substance — a cell, a row, a run -of text, a framed image. A **Cue** is the support UI around it — a -label, a handle, a well, a popup. A **Coordinate** is a typed location -inside a parent **Coordinate Space**, not a DOM path. **Posture** -(`use` vs `compose`) and **Inspect** (overlay on / off) are independent -dials that cascade through the network. **Lift** shows a surface -elsewhere; **Place** moves one into another. The Concepts tier in the -workbench defines each in one screen. - -> DOM and component renderers produce trees. -> *Surfaces produces a network over those trees.* - -## Project layout - -``` -boxel-surface/ -├── LICENSE -├── README.md -├── src/ # Engine + foundation primitives -│ ├── index.ts # Public surface — re-exports everything -│ ├── components/ # Glimmer/Ember bindings -│ │ ├── surface-component.gts # Environment + 14 surface kinds -│ │ ├── lift.gts # Lift component + chrome variants -│ │ ├── accessory.gts # Accessory + CueLabel/Description/Status -│ │ ├── form*.gts # Form, FormField, FormSection, … -│ │ └── (cell components, etc.) -│ ├── modifiers/ # grid/canvas/scene bindings, decals, -│ │ # root/node, inline edit, portal, lift binding -│ ├── contracts.ts # ContractKey table, Contract / Capability types, -│ │ # negotiation, BASE_CONTRACTS for every pair -│ ├── focus-ladder.ts # Focus/selection traversal compatibility bridge -│ ├── lift-edges.ts # Declarative @lift edges + LiftManager -│ ├── lift-state.ts # Lift state machine -│ ├── rules.ts # CSS-selector pattern matching (adaptive dialect) -│ ├── canvas-dom.ts # Canvas DOM registry -│ ├── grid-dom.ts # Grid DOM registry -│ ├── dom-registry.ts # Shared DOM registry primitives -│ ├── foci-*.ts # Foci policy / projection / store -│ ├── surface-runtime.ts # SurfaceRuntime + scoped subscriptions -│ ├── surface-contexts.ts # Provided contexts (Mode, Inspect, …) -│ ├── form-field-*.ts # Form field context + resolution -│ ├── geometry-events.ts, keyboard.ts, layer-manager.ts, -│ ├── relative-scale.ts, resize-stability.ts, scope-relay.ts, -│ ├── template-helpers.ts, widget.ts -│ ├── icons/ # Inline SVG icon components -│ ├── styles/ # Shared CSS used by components -│ ├── themes/ # Theme tokens (boxel default theme) -│ └── thimble/ # Default thimble visuals (CSS + tokens) -└── packages/ - └── boxel-layout/ # Layout primitive (separate package boundary) - ├── index.ts - └── components/layout.gts -``` - -## Status - -`0.10.0` — pre-release. The engine surface (foundations, -SurfaceRuntime, contracts, lift edges, rules, and package bindings) is -stable enough to build apps against. Default thimble implementations, -the rule library, and the Build track lessons are in active -development. - - -## License - -[MIT](./LICENSE) © Cardstack \ No newline at end of file diff --git a/boxel-surface/packages/boxel-layout/components/layout.gts b/boxel-surface/packages/boxel-layout/components/layout.gts deleted file mode 100644 index e06a4db9..00000000 --- a/boxel-surface/packages/boxel-layout/components/layout.gts +++ /dev/null @@ -1,211 +0,0 @@ -import Component from '@glimmer/component'; -import { consume } from 'ember-provide-consume-context'; -import { - InspectContextName, - Layout as FoundationLayout, - ModeContextName, - type SurfaceComponentSignature as FoundationSignature, - type FociNodePolicy, - type Target, -} from '../../../src/components/surface-component.gts'; - -export type LayoutPreset = 'bare' | 'page' | 'notebook' | 'tools'; - -export interface LayoutSignature { - Args: FoundationSignature['Args'] & { - /** Visual layout preset. `bare` keeps the foundation surface headless. */ - preset?: LayoutPreset; - }; - Blocks: { - default: []; - }; - Element: HTMLElement; -} - -export default class Layout extends Component { - @consume(InspectContextName) declare inheritedInspect: boolean | undefined; - @consume(ModeContextName) declare inheritedMode: - | 'use' - | 'change' - | 'inspect' - | undefined; - - get preset(): LayoutPreset { - return this.args.preset ?? 'page'; - } - - get rootClass(): string { - return ['boxel-layout', `boxel-layout--${this.preset}`].join(' '); - } - - get inspect(): boolean { - const mode = this.args.mode ?? this.inheritedMode; - return this.args.inspect ?? this.inheritedInspect ?? mode === 'inspect'; - } - - get runtimePolicy(): FociNodePolicy | undefined { - const policy: FociNodePolicy = { - ...(this.args.runtimePolicy ?? {}), - }; - - if (!this.inspect) { - policy.adornments = { - focus: 'none', - selection: 'none', - source: 'none', - context: 'none', - hover: 'none', - inspect: 'none', - ...(policy.adornments ?? {}), - }; - } - - return Object.keys(policy).length > 0 ? policy : undefined; - } - - get target(): Target | undefined { - return this.args.target ?? (this.inspect ? undefined : 'structure'); - } - - -} diff --git a/boxel-surface/packages/boxel-layout/index.ts b/boxel-surface/packages/boxel-layout/index.ts deleted file mode 100644 index 77309be3..00000000 --- a/boxel-surface/packages/boxel-layout/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as Layout } from './components/layout.gts'; -export type { LayoutPreset, LayoutSignature } from './components/layout.gts'; diff --git a/boxel-surface/src/canvas-dom.ts b/boxel-surface/src/canvas-dom.ts deleted file mode 100644 index 19773b47..00000000 --- a/boxel-surface/src/canvas-dom.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - surfaceElementForId, - surfaceRuntimeForElement, -} from './dom-registry.ts'; -import { isSurfaceTextEntryTarget } from './keyboard.ts'; -import type { SurfaceRuntime } from './surface-runtime.ts'; - -export interface SurfaceCanvasDomOptions { - root?: Document | HTMLElement | null; - focusDom?: boolean; - reveal?: boolean; - restoreSource?: boolean; -} - -export function restoreSurfaceCanvasSelection( - id: string, - options: SurfaceCanvasDomOptions = {}, -): boolean { - const target = surfaceCanvasElementForId(id, options.root); - const runtime = target ? surfaceRuntimeForElement(target) : undefined; - if (!target || !runtime) return false; - - runtime.select(id, { restoreSource: options.restoreSource ?? true }); - if (options.focusDom) - focusSurfaceCanvasObject(target, options.reveal ?? false); - return runtimeOwnsSelection(runtime, id); -} - -export function clearSurfaceCanvasSelection( - root: Document | HTMLElement | null | undefined, -): boolean { - const rootElement = rootElementFor(root); - const runtime = rootElement - ? surfaceRuntimeForElement(rootElement) - : undefined; - if (!rootElement || !runtime) return false; - rootElement.dataset['surfaceCanvasSelectionCleared'] = 'true'; - runtime.clearInteractionState(); - releaseSurfaceCanvasDomFocus(rootElement); - return true; -} - -export function releaseSurfaceCanvasDomFocus( - root: Document | HTMLElement | null | undefined, -): boolean { - const rootElement = rootElementFor(root); - const active = rootElement?.ownerDocument.activeElement; - if (!rootElement || !(active instanceof HTMLElement)) return false; - if (!rootElement.contains(active)) return false; - if (surfaceTargetRetainsFocus(active)) return false; - - const activeObject = active.closest( - '[data-surface-component="frame"][data-canvas-object], [data-canvas-object], [data-surface-canvas-object]', - ); - if (!activeObject || !rootElement.contains(activeObject)) return false; - - active.blur(); - return rootElement.ownerDocument.activeElement !== active; -} - -function surfaceCanvasElementForId( - id: string, - root: Document | HTMLElement | null | undefined, -): HTMLElement | null { - const rootElement = rootElementFor(root); - if (!rootElement) return null; - return ( - surfaceElementForId(rootElement, id) ?? - rootElement.ownerDocument.getElementById(id) - ); -} - -function rootElementFor( - root: Document | HTMLElement | null | undefined, -): HTMLElement | null { - if (root && 'nodeType' in root && root.nodeType === 1) { - return root as HTMLElement; - } - if (root && 'documentElement' in root) { - return ( - root.querySelector('[data-surface-canvas-binding="active"]') ?? - root.documentElement - ); - } - if (typeof document !== 'undefined') { - return ( - document.querySelector('[data-surface-canvas-binding="active"]') ?? - document.documentElement - ); - } - return null; -} - -function focusSurfaceCanvasObject(object: HTMLElement, reveal: boolean): void { - if (!surfaceTargetRetainsFocus(object.ownerDocument.activeElement)) { - object.focus({ preventScroll: true }); - } - if (reveal) { - object.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - } -} - -function surfaceTargetRetainsFocus(target: Element | null): boolean { - if (!target) return false; - return ( - isSurfaceTextEntryTarget(target) || - target.closest('[data-surface-keyboard-owner], [data-bx-lift]') !== null - ); -} - -function runtimeOwnsSelection(runtime: SurfaceRuntime, id: string): boolean { - const snapshot = runtime.snapshot(); - if (snapshot.focusedId !== id) return false; - return Object.values(snapshot.selections).some( - (selection) => - selection.headId === id && - selection.ids.length === 1 && - selection.ids[0] === id, - ); -} diff --git a/boxel-surface/src/components.ts b/boxel-surface/src/components.ts deleted file mode 100644 index 8b1a2c27..00000000 --- a/boxel-surface/src/components.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Public component barrel, shaped like Boxel UI's addon-level components entry. - -export { - SurfaceComponent, - SurfaceComponent as AbstractFoundation, - Environment, - Layout, - Canvas, - Scene, - Grid, - Row, - Scroll, - Flow, - Frame, - Pane, - Plane, - Outline, - Cell, - Run, - Unit, - nextSurfaceId, - surfaceFocusKey, - surfaceFocusKeyFromPath, - surfaceId, - surfaceIdFromPath, - LadderContextName, - ParentIdContextName, - ParentContextName, - DemoContextName, - ModeContextName, - InspectContextName, - ChangeRouteContextName, - CoordinateSpaceContextName, - PathContextName, -} from './components/surface-component.gts'; -export type { - SurfaceComponentSignature, - SurfaceComponentSignature as FoundationSignature, - EnvironmentSignature, - ChangeInput, - ChangePreference, - ChangeRoute, - CoordinateSpaceContext, - CoordinateSpace, - DemoMode, - Identity, - IdentityPart, - KeyboardMode, - LocalCoordinate, - Mode, - Posture, - Path, - Role, - DirectiveScope, -} from './components/surface-component.gts'; - -export { - CueDescription, - CueLabel, - CueStatus, - Accessory, -} from './components/accessory.gts'; -export type { - AccessoryAliasSignature, - AccessoryKind, - AccessoryPosition, - AccessorySignature, - AccessoryTone, -} from './components/accessory.gts'; - -export { default as Lift } from './components/lift.gts'; -export type { LiftSignature } from './components/lift.gts'; - -export { default as LiftChevron } from './components/lift-chevron.gts'; -export type { LiftChevronSignature } from './components/lift-chevron.gts'; - -/** @deprecated Use `Cell`. Removed in v3. */ -export { Cell as FieldCell } from './components/surface-component.gts'; -export type { - CellSignature, - CellSignature as FieldCellSignature, - CellState, - CellSurface, - CellSurface as FieldCellSurface, - CellValidationState, -} from './components/surface-component.gts'; - -export { default as Form } from './components/form.gts'; -export type { FormSignature } from './components/form.gts'; - -export { default as FormField } from './components/form-field.gts'; -export type { FormFieldSignature } from './components/form-field.gts'; - -export { default as FormSection } from './components/form-section.gts'; -export type { FormSectionSignature } from './components/form-section.gts'; - -export { default as FormTab } from './components/form-tab.gts'; -export type { FormTabSignature } from './components/form-tab.gts'; - -export { default as FormTabs } from './components/form-tabs.gts'; -export type { FormTabsSignature } from './components/form-tabs.gts'; - -export { default as FormStep } from './components/form-step.gts'; -export type { FormStepSignature } from './components/form-step.gts'; - -export { default as FormWizard } from './components/form-wizard.gts'; -export type { FormWizardSignature } from './components/form-wizard.gts'; - -export { default as FormAlert } from './components/form-alert.gts'; -export type { - FormAlertSeverity, - FormAlertSignature, -} from './components/form-alert.gts'; - -export { default as TextCell } from './components/text-cell.gts'; -export type { TextCellSignature } from './components/text-cell.gts'; - -export { default as EmailCell } from './components/email-cell.gts'; -export type { EmailCellSignature } from './components/email-cell.gts'; - -export { default as NumberCell } from './components/number-cell.gts'; -export type { NumberCellSignature } from './components/number-cell.gts'; - -export { default as SwitchCell } from './components/switch-cell.gts'; -export type { SwitchCellSignature } from './components/switch-cell.gts'; - -export { - labelForFieldKey, - readResolvedFormFieldValue, - resolveFormFields, - writeResolvedFormFieldValue, -} from './form-field-resolution.ts'; -export type { - FormMode, - ResolvedFormField, - ResolvedFormFieldInput, - ResolvedFormFieldKind, - ResolvedFormModel, -} from './form-field-resolution.ts'; diff --git a/boxel-surface/src/components/accessory.gts b/boxel-surface/src/components/accessory.gts deleted file mode 100644 index da58cfc6..00000000 --- a/boxel-surface/src/components/accessory.gts +++ /dev/null @@ -1,134 +0,0 @@ -import Component from '@glimmer/component'; - -export type AccessoryKind = 'label' | 'description' | 'status'; -export type AccessoryPosition = - | 'block-start' - | 'block-end' - | 'inline-start' - | 'inline-end'; -export type AccessoryTone = 'neutral' | 'info' | 'success' | 'warn' | 'danger'; - -export interface AccessorySignature { - Args: { - id?: string; - kind?: AccessoryKind; - labelFor?: string; - position?: AccessoryPosition; - tone?: AccessoryTone; - decorative?: boolean; - }; - Blocks: { - default: []; - }; - Element: HTMLSpanElement; -} - -export interface AccessoryAliasSignature { - Args: { - id?: string; - for?: string; - labelFor?: string; - position?: AccessoryPosition; - tone?: AccessoryTone; - decorative?: boolean; - }; - Blocks: { - default: []; - }; - Element: HTMLSpanElement; -} - -export class Accessory extends Component { - get kind(): AccessoryKind { - return this.args.kind ?? 'label'; - } - - get position(): AccessoryPosition { - return this.args.position ?? 'block-start'; - } - - get tone(): AccessoryTone { - return this.args.tone ?? 'neutral'; - } - - get id(): string | undefined { - return this.args.id ?? this.generatedId; - } - - get generatedId(): string | undefined { - if (!this.args.labelFor) return undefined; - return `${this.args.labelFor}-${this.kind}`; - } - - get role(): string | undefined { - if (this.kind === 'status') return 'status'; - return undefined; - } - - get ariaLive(): string | undefined { - if (this.kind === 'status') return 'polite'; - return undefined; - } - - get ariaHidden(): string | undefined { - if (this.args.decorative) return 'true'; - return undefined; - } - - -} - -abstract class SurfaceAccessoryAlias extends Component { - abstract get kind(): AccessoryKind; - - get labelFor(): string | undefined { - return this.args.for ?? this.args.labelFor; - } - - -} - -export class CueLabel extends SurfaceAccessoryAlias { - get kind(): AccessoryKind { - return 'label'; - } -} - -export class CueDescription extends SurfaceAccessoryAlias { - get kind(): AccessoryKind { - return 'description'; - } -} - -export class CueStatus extends SurfaceAccessoryAlias { - get kind(): AccessoryKind { - return 'status'; - } -} diff --git a/boxel-surface/src/components/accessory/index.gts b/boxel-surface/src/components/accessory/index.gts deleted file mode 100644 index e02f7d36..00000000 --- a/boxel-surface/src/components/accessory/index.gts +++ /dev/null @@ -1 +0,0 @@ -export * from '../accessory.gts'; diff --git a/boxel-surface/src/components/email-cell.gts b/boxel-surface/src/components/email-cell.gts deleted file mode 100644 index 34223738..00000000 --- a/boxel-surface/src/components/email-cell.gts +++ /dev/null @@ -1,75 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { consume } from 'ember-provide-consume-context'; - -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; -import type { - CellValidationState, - FociNodePolicy, -} from './surface-component.gts'; -import { Cell } from './surface-component.gts'; - -export interface EmailCellSignature { - Args: { - value?: string; - placeholder?: string; - state?: CellValidationState; - disabled?: boolean; - readonly?: boolean; - onInput?: (value: string) => void; - runtimePolicy?: FociNodePolicy; - }; - Element: HTMLElement; -} - -export default class EmailCell extends Component { - @consume(FormFieldContextName) declare inheritedFormField: - | FormFieldContext - | undefined; - - get state(): CellValidationState { - return this.args.state ?? this.inheritedFormField?.state ?? 'none'; - } - - get isInvalid(): boolean { - return this.state === 'invalid'; - } - - get isReadonly(): boolean { - return this.args.readonly ?? this.inheritedFormField?.readonly ?? false; - } - - get isDisabled(): boolean { - return this.args.disabled ?? this.inheritedFormField?.disabled ?? false; - } - - @action - handleInput(event: Event): void { - this.args.onInput?.((event.target as HTMLInputElement).value); - } - - -} diff --git a/boxel-surface/src/components/form-alert.gts b/boxel-surface/src/components/form-alert.gts deleted file mode 100644 index 5105beed..00000000 --- a/boxel-surface/src/components/form-alert.gts +++ /dev/null @@ -1,141 +0,0 @@ -import Component from '@glimmer/component'; -import { SuccessBordered, Warning, ExclamationCircle } from '../icons/index.ts'; - -export type FormAlertSeverity = 'error' | 'warning' | 'info' | 'success'; - -export interface FormAlertSignature { - Args: { - type?: FormAlertSeverity; - }; - Blocks: { - default: []; - messages: []; - actions: []; - }; - Element: HTMLElement; -} - -export default class FormAlert extends Component { - get type(): FormAlertSeverity { - return this.args.type ?? 'info'; - } - - get isError(): boolean { - return this.type === 'error'; - } - - get isWarning(): boolean { - return this.type === 'warning'; - } - - get isSuccess(): boolean { - return this.type === 'success'; - } - - get isInfo(): boolean { - return this.type === 'info'; - } - - -} diff --git a/boxel-surface/src/components/form-body.gts b/boxel-surface/src/components/form-body.gts deleted file mode 100644 index ae4c84d5..00000000 --- a/boxel-surface/src/components/form-body.gts +++ /dev/null @@ -1,111 +0,0 @@ -import type { ComponentLike } from '@glint/template'; -import Component from '@glimmer/component'; - -import FormAlert from './form-alert.gts'; -import FormField from './form-field.gts'; -import FormResolvedField from './form-resolved-field.gts'; -import type { - FormMode, - ResolvedFormField, - ResolvedFormModel, -} from '../form-field-resolution.ts'; - -export interface FormBodySignature { - Args: { - description?: string; - errors?: readonly string[]; - fields: Record>; - hasDefaultBlock: boolean; - hasFooterBlock: boolean; - hasHeaderBlock: boolean; - heading?: string; - helperText?: string; - isFieldset: boolean; - labelFor: (key: string) => string; - layout: 'vertical' | 'horizontal'; - mode: FormMode; - model?: ResolvedFormModel; - resolvedFields: readonly ResolvedFormField[]; - }; - Blocks: { - default: []; - header: []; - footer: []; - }; - Element: HTMLElement; -} - -export default class FormBody extends Component { - get hasHeader(): boolean { - return Boolean(this.args.heading || this.args.description); - } - - get hasErrors(): boolean { - return Boolean(this.args.errors?.length); - } - - get hasResolvedFields(): boolean { - return this.args.resolvedFields.length > 0; - } - - -} diff --git a/boxel-surface/src/components/form-field.gts b/boxel-surface/src/components/form-field.gts deleted file mode 100644 index 92a48966..00000000 --- a/boxel-surface/src/components/form-field.gts +++ /dev/null @@ -1,354 +0,0 @@ -import { guidFor } from '@ember/object/internals'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { modifier } from 'ember-modifier'; -import ContextProvider from 'ember-provide-consume-context/components/context-provider'; - -import { - FailureBordered, - type Icon, - LoadingIndicator, - SuccessBordered, -} from '../icons/index.ts'; - -import type { FormMode } from '../form-field-resolution.ts'; -import type { CellValidationState } from './surface-component.gts'; -import type { IdentityPart } from './surface-component.gts'; -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; - -export interface FormFieldSignature { - Args: { - label: string; - icon?: Icon; - optional?: boolean; - required?: boolean; - helperText?: string; - errorMessage?: string; - state?: CellValidationState; - layout?: 'vertical' | 'horizontal'; - disabled?: boolean; - readonly?: boolean; - key?: IdentityPart | IdentityPart[]; - }; - Blocks: { - default: []; - label: []; - }; - Element: HTMLElement; -} - -export default class FormField extends Component { - private guid = guidFor(this); - @tracked private inheritedLayout: 'vertical' | 'horizontal' = 'vertical'; - @tracked private inheritedDensity: 'comfortable' | 'compact' = 'comfortable'; - @tracked private inheritedMode: FormMode = 'edit'; - - get effectiveReadonly(): boolean | undefined { - if (this.args.readonly !== undefined) return this.args.readonly; - if (this.inheritedMode === 'view') return true; - return undefined; - } - - inheritFormChrome = modifier((el: HTMLElement) => { - let form = el.closest('[data-bx-form]'); - let layout = form?.getAttribute('data-bx-form-layout'); - let density = form?.getAttribute('data-bx-form-density'); - let mode = form?.getAttribute('data-bx-form-mode'); - this.inheritedLayout = layout === 'horizontal' ? 'horizontal' : 'vertical'; - this.inheritedDensity = density === 'compact' ? 'compact' : 'comfortable'; - this.inheritedMode = mode === 'view' || mode === 'create' ? mode : 'edit'; - }); - - get state(): CellValidationState { - return this.args.state ?? (this.args.errorMessage ? 'invalid' : 'none'); - } - - get layout(): 'vertical' | 'horizontal' { - return this.args.layout ?? this.inheritedLayout; - } - - get density(): 'comfortable' | 'compact' { - return this.inheritedDensity; - } - - get isHorizontal(): boolean { - return this.layout === 'horizontal'; - } - - get isVertical(): boolean { - return !this.isHorizontal; - } - - get isInvalid(): boolean { - return this.state === 'invalid'; - } - - get isValid(): boolean { - return this.state === 'valid'; - } - - get isLoading(): boolean { - return this.state === 'loading'; - } - - get shouldShowMessage(): boolean { - return Boolean(this.args.errorMessage || this.args.helperText); - } - - get helperId(): string { - return `bx-form-field-helper-${this.guid}`; - } - - get errorId(): string { - return `bx-form-field-error-${this.guid}`; - } - - get describedBy(): string | undefined { - if (this.args.errorMessage) return this.errorId; - if (this.args.helperText) return this.helperId; - return undefined; - } - - get surfaceKey(): IdentityPart | IdentityPart[] { - return this.args.key ?? [this.args.label, this.guid]; - } - - get context(): FormFieldContext { - return { - state: this.state, - layout: this.layout, - density: this.density, - surfaceKey: this.surfaceKey, - describedBy: this.describedBy, - invalid: this.isInvalid, - disabled: this.args.disabled, - readonly: this.effectiveReadonly, - required: this.args.required, - }; - } - - get stateIcon(): Icon | undefined { - switch (this.state) { - case 'valid': - return SuccessBordered; - case 'invalid': - return FailureBordered; - case 'loading': - return LoadingIndicator; - default: - return undefined; - } - } - - -} diff --git a/boxel-surface/src/components/form-resolved-field.gts b/boxel-surface/src/components/form-resolved-field.gts deleted file mode 100644 index 4818bff2..00000000 --- a/boxel-surface/src/components/form-resolved-field.gts +++ /dev/null @@ -1,160 +0,0 @@ -import { action } from '@ember/object'; -import Component from '@glimmer/component'; - -import type { - FormMode, - ResolvedFormField, - ResolvedFormModel, -} from '../form-field-resolution.ts'; -import { - readResolvedFormFieldValue, - writeResolvedFormFieldValue, -} from '../form-field-resolution.ts'; -import EmailCell from './email-cell.gts'; -import FormField from './form-field.gts'; -import NumberCell from './number-cell.gts'; -import SwitchCell from './switch-cell.gts'; -import TextCell from './text-cell.gts'; - -export interface FormResolvedFieldSignature { - Args: { - field: ResolvedFormField; - model?: ResolvedFormModel; - mode: FormMode; - }; - Element: HTMLElement; -} - -export default class FormResolvedField extends Component { - get rawValue(): unknown { - return readResolvedFormFieldValue(this.args.field, this.args.model); - } - - get textValue(): string { - return this.rawValue == null ? '' : String(this.rawValue); - } - - get numberValue(): string | number { - return typeof this.rawValue === 'number' ? this.rawValue : this.textValue; - } - - get booleanValue(): boolean { - return Boolean(this.rawValue); - } - - get isEmail(): boolean { - return this.args.field.kind === 'email'; - } - - get isNumber(): boolean { - return this.args.field.kind === 'number'; - } - - get isBoolean(): boolean { - return this.args.field.kind === 'boolean'; - } - - get isReadonly(): boolean { - return this.args.mode === 'view' || this.args.field.readonly === true; - } - - get isDisabled(): boolean { - return this.args.field.disabled === true; - } - - get isBooleanDisabled(): boolean { - return this.isDisabled || this.args.mode === 'view'; - } - - @action - updateText(value: string): void { - if (this.isReadonly || this.isDisabled) return; - this.args.field.onInput?.(value); - if (!this.args.field.onInput) { - writeResolvedFormFieldValue(this.args.field, this.args.model, value); - } - } - - @action - updateNumber(value: string): void { - if (this.isReadonly || this.isDisabled) return; - this.args.field.onInput?.(value); - if (this.args.field.onInput) return; - - let nextValue: string | number = value; - if (typeof this.rawValue === 'number' && value !== '') { - nextValue = Number(value); - } - writeResolvedFormFieldValue(this.args.field, this.args.model, nextValue); - } - - @action - updateBoolean(value: boolean): void { - if (this.isBooleanDisabled) return; - this.args.field.onChange?.(value); - if (!this.args.field.onChange) { - writeResolvedFormFieldValue(this.args.field, this.args.model, value); - } - } - - -} diff --git a/boxel-surface/src/components/form-section.gts b/boxel-surface/src/components/form-section.gts deleted file mode 100644 index c75f53fe..00000000 --- a/boxel-surface/src/components/form-section.gts +++ /dev/null @@ -1,196 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; - -type FormSectionColumns = 1 | 2 | 3; - -export interface FormSectionSignature { - Args: { - heading: string; - description?: string; - collapsible?: boolean; - defaultOpen?: boolean; - columns?: FormSectionColumns; - }; - Blocks: { - default: []; - actions: []; - }; - Element: HTMLElement; -} - -export default class FormSection extends Component { - @tracked private openOverride: boolean | undefined; - - get columns(): FormSectionColumns { - return this.args.columns ?? 1; - } - - get isOpen(): boolean { - if (!this.args.collapsible) return true; - return this.openOverride ?? this.args.defaultOpen ?? true; - } - - @action - toggle(): void { - if (!this.args.collapsible) return; - this.openOverride = !this.isOpen; - } - - -} diff --git a/boxel-surface/src/components/form-step.gts b/boxel-surface/src/components/form-step.gts deleted file mode 100644 index 8b78399c..00000000 --- a/boxel-surface/src/components/form-step.gts +++ /dev/null @@ -1,110 +0,0 @@ -import Component from '@glimmer/component'; -import { guidFor } from '@ember/object/internals'; -import { tracked } from '@glimmer/tracking'; -import { modifier } from 'ember-modifier'; -import { consume } from 'ember-provide-consume-context'; - -import { eq } from '../template-helpers.ts'; - -import { - FormStepRegisterEventName, - FormWizardContextName, - type FormWizardContext, -} from './form-wizard.gts'; - -export interface FormStepSignature { - Args: { - id?: string; - label: string; - disabled?: boolean; - canAdvance?: boolean; - }; - Blocks: { - default: []; - }; - Element: HTMLElement; -} - -export default class FormStep extends Component { - private guid = guidFor(this); - @tracked private eventActiveId: string | undefined; - - @consume(FormWizardContextName) declare wizard: FormWizardContext | undefined; - - get id(): string { - return this.args.id ?? this.guid; - } - - get stepId(): string { - return `bx-form-step-${this.guid}`; - } - - get panelId(): string { - return `bx-form-step-panel-${this.guid}`; - } - - get isActive(): boolean { - return (this.wizard?.activeId ?? this.eventActiveId) === this.id; - } - - register = modifier((el: HTMLElement) => { - let step = { - id: this.id, - label: this.args.label, - stepId: this.stepId, - panelId: this.panelId, - disabled: this.args.disabled, - canAdvance: this.args.canAdvance, - }; - let contextUnregister = this.wizard?.register(step); - if (contextUnregister) return contextUnregister; - - let unregister: (() => void) | undefined; - let cancelled = false; - queueMicrotask(() => { - if (cancelled) return; - el.dispatchEvent( - new CustomEvent(FormStepRegisterEventName, { - bubbles: true, - detail: { - step, - updateActiveId: (id: string | undefined) => { - this.eventActiveId = id; - }, - setUnregister: (next: () => void) => { - unregister = next; - }, - }, - }), - ); - }); - - return () => { - cancelled = true; - unregister?.(); - }; - }); - - -} diff --git a/boxel-surface/src/components/form-tab.gts b/boxel-surface/src/components/form-tab.gts deleted file mode 100644 index 7e8cb632..00000000 --- a/boxel-surface/src/components/form-tab.gts +++ /dev/null @@ -1,109 +0,0 @@ -import Component from '@glimmer/component'; -import { guidFor } from '@ember/object/internals'; -import { tracked } from '@glimmer/tracking'; -import { modifier } from 'ember-modifier'; -import { consume } from 'ember-provide-consume-context'; - -import { eq } from '../template-helpers.ts'; - -import { - FormTabsContextName, - FormTabRegisterEventName, - type FormTabsContext, -} from './form-tabs.gts'; - -export interface FormTabSignature { - Args: { - id?: string; - label: string; - disabled?: boolean; - }; - Blocks: { - default: []; - }; - Element: HTMLElement; -} - -export default class FormTab extends Component { - private guid = guidFor(this); - @tracked private eventActiveId: string | undefined; - - @consume(FormTabsContextName) declare tabs: FormTabsContext | undefined; - - get id(): string { - return this.args.id ?? this.guid; - } - - get tabId(): string { - return `bx-form-tab-${this.guid}`; - } - - get panelId(): string { - return `bx-form-tab-panel-${this.guid}`; - } - - get isActive(): boolean { - return (this.tabs?.activeId ?? this.eventActiveId) === this.id; - } - - register = modifier((el: HTMLElement) => { - let tab = { - id: this.id, - label: this.args.label, - tabId: this.tabId, - panelId: this.panelId, - disabled: this.args.disabled, - }; - let contextUnregister = this.tabs?.register(tab); - if (contextUnregister) return contextUnregister; - - let unregister: (() => void) | undefined; - let cancelled = false; - queueMicrotask(() => { - if (cancelled) return; - el.dispatchEvent( - new CustomEvent(FormTabRegisterEventName, { - bubbles: true, - detail: { - tab, - updateActiveId: (id: string | undefined) => { - this.eventActiveId = id; - }, - setUnregister: (next: () => void) => { - unregister = next; - }, - }, - }), - ); - }); - - return () => { - cancelled = true; - unregister?.(); - }; - }); - - -} diff --git a/boxel-surface/src/components/form-tabs.gts b/boxel-surface/src/components/form-tabs.gts deleted file mode 100644 index 3b23a6c3..00000000 --- a/boxel-surface/src/components/form-tabs.gts +++ /dev/null @@ -1,246 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; - -import { eq } from '../template-helpers.ts'; - -export interface FormTabRegistration { - id: string; - label: string; - tabId: string; - panelId: string; - disabled?: boolean; -} - -export interface FormTabsContext { - activeId?: string; - register: (tab: FormTabRegistration) => () => void; -} - -export const FormTabsContextName = 'boxel-surface:form-tabs'; -export const FormTabRegisterEventName = 'bx-form-tab-register'; - -export interface FormTabRegisterEventDetail { - tab: FormTabRegistration; - updateActiveId: (id: string | undefined) => void; - setUnregister: (unregister: () => void) => void; -} - -export interface FormTabsSignature { - Args: { - activeTab?: string; - defaultTab?: string; - onChange?: (id: string) => void; - }; - Blocks: { - default: []; - }; - Element: HTMLElement; -} - -export default class FormTabs extends Component { - @tracked private tabs: FormTabRegistration[] = []; - @tracked private activeOverride: string | undefined; - private tabUpdaters = new Map void>(); - - get activeId(): string | undefined { - return ( - this.activeOverride ?? - this.args.activeTab ?? - this.args.defaultTab ?? - this.tabs.find((tab) => !tab.disabled)?.id ?? - this.tabs[0]?.id - ); - } - - registerTab = ( - tab: FormTabRegistration, - updateActiveId?: (id: string | undefined) => void, - ): (() => void) => { - let existingIndex = this.tabs.findIndex( - (candidate) => candidate.id === tab.id, - ); - if (existingIndex === -1) { - this.tabs = [...this.tabs, tab]; - } else { - this.tabs = this.tabs.map((candidate, index) => - index === existingIndex ? tab : candidate, - ); - } - if (updateActiveId) { - this.tabUpdaters.set(tab.id, updateActiveId); - } - - this.syncPanels(); - - return () => { - this.tabs = this.tabs.filter((candidate) => candidate.id !== tab.id); - this.tabUpdaters.delete(tab.id); - if (this.activeOverride === tab.id) { - this.activeOverride = undefined; - } - this.syncPanels(); - }; - }; - - private syncPanels(): void { - for (let update of this.tabUpdaters.values()) { - update(this.activeId); - } - } - - get context(): FormTabsContext { - return { - activeId: this.activeId, - register: this.registerTab, - }; - } - - @action - select(id: string): void { - let tab = this.tabs.find((candidate) => candidate.id === id); - if (!tab || tab.disabled) return; - this.activeOverride = id; - this.syncPanels(); - this.args.onChange?.(id); - } - - @action - selectFromEvent(event: Event): void { - let id = (event.currentTarget as HTMLElement).dataset['bxFormTabId']; - if (!id) return; - this.select(id); - } - - @action - registerFromEvent(event: Event): void { - let detail = (event as CustomEvent).detail; - if (!detail) return; - event.stopPropagation(); - let unregister = this.registerTab(detail.tab, detail.updateActiveId); - detail.setUnregister(unregister); - } - - -} diff --git a/boxel-surface/src/components/form-wizard.gts b/boxel-surface/src/components/form-wizard.gts deleted file mode 100644 index dbb32c80..00000000 --- a/boxel-surface/src/components/form-wizard.gts +++ /dev/null @@ -1,381 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; - -import { add, eq, lt } from '../template-helpers.ts'; - -export interface FormStepRegistration { - id: string; - label: string; - stepId: string; - panelId: string; - disabled?: boolean; - canAdvance?: boolean; -} - -export interface FormWizardContext { - activeId?: string; - register: (step: FormStepRegistration) => () => void; -} - -export const FormWizardContextName = 'boxel-surface:form-wizard'; -export const FormStepRegisterEventName = 'bx-form-step-register'; - -export interface FormStepRegisterEventDetail { - step: FormStepRegistration; - updateActiveId: (id: string | undefined) => void; - setUnregister: (unregister: () => void) => void; -} - -export interface FormWizardSignature { - Args: { - activeStep?: string; - defaultStep?: string; - nextLabel?: string; - previousLabel?: string; - finishLabel?: string; - onStepChange?: (id: string) => void; - onFinish?: () => void; - }; - Blocks: { - default: []; - footer: []; - }; - Element: HTMLElement; -} - -export default class FormWizard extends Component { - @tracked private steps: FormStepRegistration[] = []; - @tracked private activeOverride: string | undefined; - private stepUpdaters = new Map void>(); - - get activeId(): string | undefined { - return ( - this.activeOverride ?? - this.args.activeStep ?? - this.args.defaultStep ?? - this.steps.find((step) => !step.disabled)?.id ?? - this.steps[0]?.id - ); - } - - get activeIndex(): number { - return this.steps.findIndex((step) => step.id === this.activeId); - } - - get activeStep(): FormStepRegistration | undefined { - return this.steps[this.activeIndex]; - } - - get isFirst(): boolean { - return this.activeIndex <= 0; - } - - get isLast(): boolean { - return this.activeIndex >= this.steps.length - 1; - } - - get canAdvance(): boolean { - return this.activeStep?.canAdvance !== false; - } - - get nextLabel(): string { - return this.args.nextLabel ?? 'Continue'; - } - - get previousLabel(): string { - return this.args.previousLabel ?? 'Back'; - } - - get finishLabel(): string { - return this.args.finishLabel ?? 'Finish'; - } - - registerStep = ( - step: FormStepRegistration, - updateActiveId?: (id: string | undefined) => void, - ): (() => void) => { - let existingIndex = this.steps.findIndex( - (candidate) => candidate.id === step.id, - ); - if (existingIndex === -1) { - this.steps = [...this.steps, step]; - } else { - this.steps = this.steps.map((candidate, index) => - index === existingIndex ? step : candidate, - ); - } - if (updateActiveId) { - this.stepUpdaters.set(step.id, updateActiveId); - } - - this.syncPanels(); - - return () => { - this.steps = this.steps.filter((candidate) => candidate.id !== step.id); - this.stepUpdaters.delete(step.id); - if (this.activeOverride === step.id) { - this.activeOverride = undefined; - } - this.syncPanels(); - }; - }; - - private syncPanels(): void { - for (let update of this.stepUpdaters.values()) { - update(this.activeId); - } - } - - get context(): FormWizardContext { - return { - activeId: this.activeId, - register: this.registerStep, - }; - } - - @action - select(id: string): void { - let nextIndex = this.steps.findIndex((step) => step.id === id); - let step = this.steps[nextIndex]; - if (!step || step.disabled) return; - if (nextIndex > this.activeIndex && !this.canAdvance) return; - this.activeOverride = id; - this.syncPanels(); - this.args.onStepChange?.(id); - } - - @action - selectFromEvent(event: Event): void { - let id = (event.currentTarget as HTMLElement).dataset['bxFormWizardStepId']; - if (!id) return; - this.select(id); - } - - @action - previous(): void { - if (this.isFirst) return; - let step = this.steps[this.activeIndex - 1]; - if (!step || step.disabled) return; - this.activeOverride = step.id; - this.syncPanels(); - this.args.onStepChange?.(step.id); - } - - @action - next(): void { - if (!this.canAdvance) return; - if (this.isLast) { - this.args.onFinish?.(); - return; - } - - let step = this.steps[this.activeIndex + 1]; - if (!step || step.disabled) return; - this.activeOverride = step.id; - this.syncPanels(); - this.args.onStepChange?.(step.id); - } - - @action - registerFromEvent(event: Event): void { - let detail = (event as CustomEvent).detail; - if (!detail) return; - event.stopPropagation(); - let unregister = this.registerStep(detail.step, detail.updateActiveId); - detail.setUnregister(unregister); - } - - -} diff --git a/boxel-surface/src/components/form.gts b/boxel-surface/src/components/form.gts deleted file mode 100644 index 683a65a8..00000000 --- a/boxel-surface/src/components/form.gts +++ /dev/null @@ -1,221 +0,0 @@ -import Component from '@glimmer/component'; -import type { ComponentLike } from '@glint/template'; - -import { element } from '../template-helpers.ts'; - -import type { - FormMode, - ResolvedFormField, - ResolvedFormFieldInput, - ResolvedFormModel, -} from '../form-field-resolution.ts'; -import { resolveFormFields } from '../form-field-resolution.ts'; -import FormBody from './form-body.gts'; - -type FormTag = 'form' | 'div' | 'section' | 'fieldset'; -type FormVariant = 'standalone' | 'embedded'; -type FormColumns = 1 | 2 | 3; - -export interface FormSignature { - Args: { - fields?: Record>; - model?: ResolvedFormModel; - resolvedFields?: readonly ResolvedFormFieldInput[]; - tag?: FormTag; - layout?: 'vertical' | 'horizontal'; - density?: 'comfortable' | 'compact'; - columns?: FormColumns; - mode?: FormMode; - heading?: string; - description?: string; - errors?: readonly string[]; - helperText?: string; - variant?: FormVariant; - }; - Blocks: { - default: []; - header: []; - footer: []; - }; - Element: HTMLElement; -} - -export default class Form extends Component { - get tag(): FormTag { - return ( - this.args.tag ?? (this.variant === 'standalone' ? 'form' : 'fieldset') - ); - } - - get variant(): FormVariant { - return this.args.variant ?? 'embedded'; - } - - get density(): 'comfortable' | 'compact' { - return this.args.density ?? 'comfortable'; - } - - get layout(): 'vertical' | 'horizontal' { - return this.args.layout ?? 'vertical'; - } - - get mode(): FormMode { - return this.args.mode ?? 'edit'; - } - - get columns(): FormColumns { - return this.args.columns ?? 1; - } - - get hasHeader(): boolean { - return Boolean(this.args.heading || this.args.description); - } - - get hasErrors(): boolean { - return Boolean(this.args.errors?.length); - } - - get isFieldset(): boolean { - return this.tag === 'fieldset'; - } - - get rootClass(): string { - return `bx-form bx-form--${this.density} bx-form--${this.layout}`; - } - - get fields(): Record> { - return this.args.fields ?? {}; - } - - get resolvedFields(): readonly ResolvedFormField[] { - return resolveFormFields( - this.args.resolvedFields, - this.labelFor.bind(this), - ); - } - - get bodyArgs() { - return { - description: this.args.description, - errors: this.args.errors, - fields: this.fields, - heading: this.args.heading, - helperText: this.args.helperText, - labelFor: this.labelFor.bind(this), - layout: this.layout, - mode: this.mode, - }; - } - - labelFor(key: string): string { - return key - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/[-_]+/g, ' ') - .replace(/\b\w/g, (match) => match.toUpperCase()); - } - - -} diff --git a/boxel-surface/src/components/foundation/index.gts b/boxel-surface/src/components/foundation/index.gts deleted file mode 100644 index 78a98e58..00000000 --- a/boxel-surface/src/components/foundation/index.gts +++ /dev/null @@ -1 +0,0 @@ -export * from '../surface-component.gts'; diff --git a/boxel-surface/src/components/lift-chevron.gts b/boxel-surface/src/components/lift-chevron.gts deleted file mode 100644 index 2e087cf8..00000000 --- a/boxel-surface/src/components/lift-chevron.gts +++ /dev/null @@ -1,170 +0,0 @@ -import Component from '@glimmer/component'; -import { fn } from '@ember/helper'; -import { on } from '@ember/modifier'; - -import type { LiftState } from '../lift-state.ts'; -import type { Contract } from '../contracts.ts'; - -/** - * `` — the small ▾ glyph that signals "this unit has - * a lift behind it" and acts as the explicit edit-open gesture. - * - * Naming: NOT ``. The chevron is host-agnostic — a - * canvas node or kanban card that supports an edit lift gets the - * same affordance. Lives in `boxel-surface` where the only allowed - * concepts are surfaces, contracts, and intent declarations. - * - * THE PROBLEM - * =========== - * - * Every host that wired the K.4 hover-to-Details + click-to-Edit - * flow was hand-rolling the same ~30 lines of template + CSS for - * the chevron affordance: an absolute-positioned button in the - * unit's right edge, three opacity tiers (rest / unit-hover / - * unit-focused / chevron-hover / lift-open), keyboard outline, - * click handler that calls `state.openEdit(row, col)`. The - * widget-lab template used `widget-lab__bx-cell-lift-btn`; the - * future grid-demo, kanban host, calendar host would each - * reinvent it. - * - * THIS COMPONENT - * ============== - * - * Pure visual + click affordance. Reads `contract.lift[]` to - * decide whether to render at all (units without `'edit'` in the - * lift chain don't get a chevron — Pattern A/B widgets like - * toggle / stars). Reads `state.isOpenFor(row, col)` so the - * chevron stays lit while its lift is open (otherwise a hover- - * bounce would re-fade it to 60%). On click, calls - * `state.openEdit(row, col)` — same as the unit's dblclick / Enter - * / F2 path, just with explicit pointer intent. - * - * VISUAL TIERS (from quietest to loudest) - * ======================================= - * - * rest opacity 0 (hidden — keep units clean) - * unit hover opacity 0.35 gray (discovery — provided by host - * CSS or Step E's cell-chrome - * stylesheet) - * unit focused opacity 0.60 indigo (ready — same host source) - * chevron hover opacity 1.0 indigo + bg (the click target) - * lift open opacity 1.0 indigo + bg (commit signal) - * - * The component owns the FIRST and LAST TWO tiers (rest baseline - * + chevron hover + lift open + focus-visible outline). The - * unit-hover and unit-focused tiers come from a parent rule on - * `.bx-cell` (or whatever the host calls its lift-target unit) — - * the host (or Step E's shared `cell-chrome.css`) provides them - * via descendant selectors. This split lets the chevron travel - * without a CSS dependency: drop `` into any - * container, the click + lift-open visibility works; pair with - * `bx-cell` (or your own equivalent) to get the full five-tier - * ladder. - * - * Splattributes are forwarded so consumers can add `data-*` test - * hooks, additional classes, or override styles via `class=`. - */ -export interface LiftChevronSignature { - Element: HTMLButtonElement; - Args: { - /** The shared lift state. The chevron clicks call - * `state.openEdit(row, col)`; reading `state.isOpenFor(row, col)` - * drives the lift-open tier. */ - state: LiftState; - /** This unit's contract. The chevron renders only when - * `contract.lift.includes('edit')` — no chevron on Pattern - * A/B units without an edit lift. */ - contract: Contract; - /** Unit coordinates. Threaded into `state.openEdit` and - * `state.isOpenFor`. */ - row: number; - col: number; - /** Override the button's `aria-label` and `title`. Default - * is `'Open editor'`. */ - label?: string; - }; -} - -export default class LiftChevron extends Component { - /** Render gate. Units whose contract doesn't list `'edit'` in - * the lift chain (Pattern A/B widgets) don't get a chevron — - * there's nothing to escalate to. */ - get supportsEdit(): boolean { - return this.args.contract.lift.includes('edit'); - } - - /** True when the lift is open AND points at THIS unit. Drives - * the loudest tier (full opacity + indigo + bg) so the - * chevron stays visible while the user is editing. */ - get isOpen(): boolean { - return this.args.state.isOpenFor(this.args.row, this.args.col); - } - - /** Accessible label. Override via `@label` for hosts that want - * a more specific verb (e.g., `'Pick a date'`, `'Choose tags'`). */ - get label(): string { - return this.args.label ?? 'Open editor'; - } - - -} diff --git a/boxel-surface/src/components/lift-chevron/index.gts b/boxel-surface/src/components/lift-chevron/index.gts deleted file mode 100644 index 8640cd06..00000000 --- a/boxel-surface/src/components/lift-chevron/index.gts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from '../lift-chevron.gts'; -export * from '../lift-chevron.gts'; diff --git a/boxel-surface/src/components/lift.gts b/boxel-surface/src/components/lift.gts deleted file mode 100644 index dad5e6fd..00000000 --- a/boxel-surface/src/components/lift.gts +++ /dev/null @@ -1,1633 +0,0 @@ -import Component from '@glimmer/component'; -import { on } from '@ember/modifier'; -import { modifier } from 'ember-modifier'; -import { consume } from 'ember-provide-consume-context'; -import { - autoUpdate, - computePosition, - flip, - hide, - offset, - shift, -} from '@floating-ui/dom'; -import type { Placement, Strategy } from '@floating-ui/dom'; - -import { SURFACE_LAYERS, type SurfaceLayerTier } from '../layer-manager.ts'; -import { - createSurfaceScopeRelay, - SurfaceScopeContextName, - type SurfaceScopeRelay, -} from '../scope-relay.ts'; -import surfaceScopeRelay from '../modifiers/scope-relay.ts'; -import type { FocusLadder } from '../focus-ladder.ts'; -import type { SurfaceRuntime } from '../surface-runtime.ts'; -import { LiftContextName, type LiftManager } from '../lift-edges.ts'; -import { - ladderForSurfaceElement, - liftManagerForSurfaceElement, - registerSurfaceDomRoot, - registerSurfaceLiftDomRoot, - surfaceRuntimeForElement, -} from '../dom-registry.ts'; -import { - LadderContextName, - SurfaceRuntimeContextName, - ModeContextName, - InspectContextName, -} from '../surface-contexts.ts'; - -/** - * `` — anchored floating surface that hosts a focused - * interaction next to a source element. - * - * **The vocabulary shift.** "Popover" is a CSS mechanism; - * "lift" is a SEMANTIC. A lift RAISES a focused something out of - * the source's small footprint without taking the user away from - * the source. - * - * **Four orthogonal dimensions** drive the visual + behavioral - * variant, all sourced from the negotiated `Contract`: - * - * kind 'details' | 'preview' | 'edit' | 'tools' - * placement 'attached' | 'shadow' | 'plane' - * size 'compact' | 'comfortable' | 'spacious' | 'auto' - * backdrop 'none' | 'tint' | 'blur' | 'scrim' - * elevation 'flat' | 'raised' | 'elevated' | 'modal' - * - * Plus `keyboardModel` ('pick' | 'edit-number' | 'edit-text' | - * 'compose') which doesn't paint, but threads through to the body - * so inner picker primitives can route keystrokes correctly. - * - * **What the host owns.** Open / close state, kind state, the - * actual content (yielded as the default block), what each kind - * means in the host's domain. The Lift owns positioning, dismissal - * plumbing (Esc + click-out), focus enter / restore, the per-kind - * + per-elevation visual chrome, and the optional scrim backdrop. - * - * **Chrome simplification.** The escalation toolbar is OFF by - * default. When `canEscalateTo` lists more than the current kind, - * a single compact glyph button appears in the top-right corner - * (✎ for edit, ⓘ for details, etc.). One click escalates. No - * labels, no full toolbar — frees the body for content. - */ - -export type LiftKind = 'details' | 'preview' | 'edit' | 'tools'; - -export type LiftPlacement = 'attached' | 'shadow' | 'plane'; - -export type LiftSize = 'compact' | 'comfortable' | 'spacious' | 'auto'; - -export type LiftBackdrop = 'none' | 'tint' | 'blur' | 'scrim'; - -export type LiftElevation = 'flat' | 'raised' | 'elevated' | 'modal'; - -export type LiftKeyboardModel = - | 'pick' - | 'edit-number' - | 'edit-text' - | 'compose'; - -export interface LiftSignature { - Args: { - /** CSS selector velcro / shadowAnchor uses to find the source. */ - anchor: string; - /** When false, lift is unmounted. Toggling preserves the lift's - * surrounding `` invocation so re-opens are cheap. */ - open: boolean; - /** Lift kind — drives the per-kind chrome variant + body content - * (host yields different content per kind). */ - kind: LiftKind; - /** Geometric mounting strategy. Default 'attached'. */ - placementMode?: LiftPlacement; - /** Geometric size class. Default 'comfortable'. Drives min / - * max width + height via CSS variables. */ - size?: LiftSize; - /** Visual separation token. Default depends on kind. */ - backdrop?: LiftBackdrop; - /** Elevation tier. Default depends on kind + placement. */ - elevation?: LiftElevation; - /** Keyboard model — names a finite-state model the body's - * picker primitives honor. The Lift exposes it as a data attr - * so primitives can read it; it doesn't drive Lift's own - * keystroke handling. */ - keyboardModel?: LiftKeyboardModel; - /** Stable per-open token. Re-renders of the same open lift keep - * this token so autofocus runs once for the open interaction, - * not after every source-data update. */ - focusToken?: string | number; - /** Move DOM focus into the lift on open + restore on close. - * Default true except for `details` kind. */ - autoFocus?: boolean; - /** Optional kinds the user can escalate to. When the array - * contains kinds OTHER than the current `@kind`, a corner - * escalation glyph button appears. Single-kind contracts - * (just the current kind) get NO chrome. */ - canEscalateTo?: LiftKind[]; - /** Fired when the user clicks an escalation glyph. */ - onEscalate?: (next: LiftKind) => void; - /** Fired on Esc / outside-click. Host sets `@open=false`. */ - onDismiss?: () => void; - /** Optional explicit surface layer tier. Defaults from placement/elevation. */ - layerTier?: SurfaceLayerTier; - /** Optional fixed z-index for hosts that already allocated a layer. */ - zIndex?: number; - /** Velcro placement override (only used when placementMode is - * 'attached'). Default 'bottom-start'. */ - placement?: Placement; - /** Visual scale multiplier for the lift surface. Generalizes - * the "scale of the rendering environment" — used by any - * scalable host (canvas zoom, 3D scene camera distance, future - * scene-graph hosts) to scale the LIFT in lockstep with how - * the rest of the host's content is being scaled. - * - * The host computes a DAMPED multiplier (the lift shouldn't - * scale 1:1 with the env — at canvas zoom 0.25 you don't want - * a popover at 25% of normal size, you want it noticeably - * smaller but still readable). Use `dampedRelativeScale(env)` - * from boxel-surface for a sane default curve. - * - * Applied via the CSS `zoom` property (standardized; supported - * in Chrome, Safari, Edge, and Firefox 126+) so the entire - * surface including its measured box scales — velcro's anchor - * positioning reads the new bbox correctly. Default 1 (no - * scaling — viewport scale). - * - * IGNORED for `'plane'` placement (modal lifts are centered on - * the viewport, not anchored to scalable host content; they - * always render at viewport scale). */ - relativeScale?: number; - }; - Blocks: { - default: [LiftKind]; - }; - Element: HTMLDivElement; -} - -/** Esc / click-out dismiss modifier. - * - * Capture-phase listeners — they fire BEFORE any bubble-phase - * handler in the lift body OR in the host's surrounding shell. - * Both paths call `stopPropagation()` so the same Esc / pointerdown - * doesn't ALSO trigger the host's grid-key handler (clearing cell - * focus) or the next cell's openEdit (when the user clicked from - * one lift directly into another cell). The lift owns dismissal, - * full stop. */ -const dismissOnOutside = modifier( - (_el: HTMLElement, [onDismiss]: [(() => void) | undefined]) => { - if (!onDismiss) return; - const onPointer = (event: PointerEvent): void => { - const target = event.target as Element | null; - if (!target) return; - // Click inside any lift body OR on a lift anchor — let it - // through (the anchor click reopens a fresh lift; the body - // click is interactive). Otherwise the click is "outside" — - // dismiss + don't let the click also fire other handlers - // (e.g., a sibling cell's onSelect). Without this, clicking - // from one cell's open lift into another cell would close - // lift A then immediately open lift B with stale focus. - if (target.closest('[data-bx-lift]')) return; - if (target.closest('[data-bx-lift-anchor]')) return; - // ember-power-select renders its dropdown options in a portal at - // document.body — treat that portal as "inside" so picking an - // option from a BoxelSelect within the lift does not dismiss it. - if (target.closest('.ember-basic-dropdown-content')) return; - onDismiss(); - }; - const onKey = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - event.preventDefault(); - // Stop here — don't let Esc bubble past the lift to the - // host's keyboard handler (which would clear cell focus - // OR cancel an unrelated state). Esc inside a lift means - // ONE thing: close THIS lift. - event.stopPropagation(); - onDismiss(); - } - }; - window.addEventListener('pointerdown', onPointer, true); - window.addEventListener('keydown', onKey, true); - return () => { - window.removeEventListener('pointerdown', onPointer, true); - window.removeEventListener('keydown', onKey, true); - }; - }, -); - -const allocateLiftLayer = modifier( - ( - element: HTMLElement, - [tier, fixedZIndex]: [SurfaceLayerTier, number | undefined], - ) => { - const z = fixedZIndex ?? SURFACE_LAYERS.allocate(tier); - element.style.setProperty('--bx-lift-z', String(z)); - element.dataset['surfaceLayerTier'] = tier; - element.dataset['surfaceLayerZ'] = String(z); - - return () => { - if (fixedZIndex === undefined) { - SURFACE_LAYERS.release(z); - } - element.style.removeProperty('--bx-lift-z'); - delete element.dataset['surfaceLayerTier']; - delete element.dataset['surfaceLayerZ']; - }; - }, -); - -const liftSurfaceRoot = modifier( - ( - element: HTMLElement, - _positional: [], - named: { - anchor?: string; - ladder?: FocusLadder; - runtime?: SurfaceRuntime; - liftManager?: LiftManager; - mode?: 'use' | 'change' | 'inspect'; - inspect?: boolean; - }, - ) => { - const anchor = named.anchor - ? element.ownerDocument.querySelector(named.anchor) - : null; - const ladder = - named.ladder ?? (anchor ? ladderForSurfaceElement(anchor) : undefined); - const runtime = - named.runtime ?? (anchor ? surfaceRuntimeForElement(anchor) : undefined); - const liftManager = - named.liftManager ?? - (anchor ? liftManagerForSurfaceElement(anchor) : undefined); - const modeRoot = anchor?.closest('[data-surface-mode]'); - const inspectRoot = anchor?.closest('[data-surface-inspect]'); - const priorMode = element.getAttribute('data-surface-mode'); - const priorInspect = element.getAttribute('data-surface-inspect'); - const syncModeAndInspect = (): void => { - const mode = - named.mode ?? - (modeRoot?.dataset['surfaceMode'] as - | 'use' - | 'change' - | 'inspect' - | undefined); - const inspectAttr = - named.inspect ?? inspectRoot?.getAttribute('data-surface-inspect'); - const inspect = - typeof inspectAttr === 'boolean' - ? inspectAttr - : inspectAttr === 'true' || inspectAttr === ''; - element.setAttribute('data-surface-mode', mode ?? 'use'); - element.setAttribute('data-surface-inspect', String(inspect)); - }; - syncModeAndInspect(); - element.setAttribute('data-surface-portaled-root', 'lift'); - const unregisterRoot = ladder - ? registerSurfaceDomRoot(element, ladder, runtime) - : undefined; - const unregisterLiftRoot = liftManager - ? registerSurfaceLiftDomRoot(element, liftManager) - : undefined; - const modeObserver = new MutationObserver(syncModeAndInspect); - if (modeRoot) { - modeObserver.observe(modeRoot, { - attributes: true, - attributeFilter: ['data-surface-mode'], - }); - } - if (inspectRoot && inspectRoot !== modeRoot) { - modeObserver.observe(inspectRoot, { - attributes: true, - attributeFilter: ['data-surface-inspect'], - }); - } - - return () => { - modeObserver.disconnect(); - unregisterLiftRoot?.(); - unregisterRoot?.(); - element.removeAttribute('data-surface-portaled-root'); - if (priorMode === null) element.removeAttribute('data-surface-mode'); - else element.setAttribute('data-surface-mode', priorMode); - if (priorInspect === null) - element.removeAttribute('data-surface-inspect'); - else element.setAttribute('data-surface-inspect', priorInspect); - }; - }, -); - -/** Shadow-anchor modifier — overlays the lift on the anchor's bbox. - * Sets top / left / min-width from anchor's getBoundingClientRect. - * Clamps to the viewport: if the lift's natural width would extend - * past the right edge, shifts left to keep the right edge inside. */ -const shadowAnchor = modifier((element: HTMLElement, [selector]: [string]) => { - const anchorEl = (): HTMLElement | null => - document.querySelector(selector); - const update = (): void => { - const a = anchorEl(); - if (!a) return; - const r = a.getBoundingClientRect(); - // Reset position-related styles before measuring so a previous - // run's shifts don't pollute the new computation. - element.style.position = 'absolute'; - element.style.top = `${window.scrollY + r.top}px`; - element.style.left = `${window.scrollX + r.left}px`; - element.style.minWidth = `${Math.round(r.width)}px`; - // Now measure the lift's actual width (after layout settled - // with the new min-width applied) and clamp to viewport. - requestAnimationFrame(() => { - const lr = element.getBoundingClientRect(); - const overflowRight = lr.right - window.innerWidth + 8; // 8px gutter - if (overflowRight > 0) { - const newLeft = window.scrollX + r.left - overflowRight; - element.style.left = `${Math.max(window.scrollX + 8, newLeft)}px`; - } - // Same for vertical — if extending past viewport bottom, - // shift up so we don't get cut off. - const overflowBottom = lr.bottom - window.innerHeight + 8; - if (overflowBottom > 0) { - const newTop = window.scrollY + r.top - overflowBottom; - element.style.top = `${Math.max(window.scrollY + 8, newTop)}px`; - } - }); - }; - update(); - const ro = new ResizeObserver(update); - const a = anchorEl(); - if (a) ro.observe(a); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return (): void => { - ro.disconnect(); - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; -}); - -const anchoredLift = modifier( - ( - floatingElement: HTMLElement, - [selector]: [string], - { - placement = 'bottom-start', - offsetOptions = 0, - strategy = 'fixed', - }: { - placement?: Placement; - offsetOptions?: number; - strategy?: Strategy; - } = {}, - ) => { - let frame = 0; - let destroyed = false; - let lastTop = ''; - let lastLeft = ''; - let lastVisibility = ''; - - const referenceElement = (): HTMLElement | SVGElement | null => - document.querySelector(selector); - - Object.assign(floatingElement.style, { - position: strategy, - top: '0px', - left: '0px', - margin: '0', - }); - - const apply = (top: string, left: string, visibility: string): void => { - if ( - top === lastTop && - left === lastLeft && - visibility === lastVisibility - ) { - return; - } - - lastTop = top; - lastLeft = left; - lastVisibility = visibility; - Object.assign(floatingElement.style, { - top, - left, - margin: '0', - visibility, - }); - }; - - const update = async (): Promise => { - frame = 0; - const reference = referenceElement(); - if (!reference) { - apply(lastTop || '0px', lastLeft || '0px', 'hidden'); - return; - } - - const { middlewareData, x, y } = await computePosition( - reference, - floatingElement, - { - middleware: [ - offset(offsetOptions), - flip(), - shift({ padding: 8 }), - hide({ strategy: 'referenceHidden' }), - ], - placement, - strategy, - }, - ); - if (destroyed) return; - - apply( - `${Math.round(y)}px`, - `${Math.round(x)}px`, - middlewareData.hide?.referenceHidden ? 'hidden' : 'visible', - ); - }; - - const schedule = (): void => { - if (frame !== 0) return; - frame = requestAnimationFrame(() => { - void update(); - }); - }; - - schedule(); - const reference = referenceElement(); - const cleanup = reference - ? autoUpdate(reference, floatingElement, schedule, { - ancestorResize: true, - ancestorScroll: true, - elementResize: true, - layoutShift: false, - animationFrame: false, - }) - : undefined; - - return (): void => { - destroyed = true; - cancelAnimationFrame(frame); - cleanup?.(); - }; - }, -); - -/** Focus-management modifier. Auto-focuses first focusable in body - * on mount; restores DOM focus to the closest focusable ancestor - * of the anchor on unmount. */ -const focusedLiftTokens = new Set(); - -function liftFocusableSelector(): string { - return [ - 'button:not([disabled]):not([tabindex="-1"])', - 'input:not([type="hidden"]):not([disabled]):not([tabindex="-1"])', - 'select:not([disabled]):not([tabindex="-1"])', - 'textarea:not([disabled]):not([tabindex="-1"])', - '[contenteditable=""]:not([tabindex="-1"])', - '[contenteditable="true"]:not([tabindex="-1"])', - '[tabindex]:not([tabindex="-1"])', - ].join(','); -} - -function liftEditorSelector(): string { - return [ - 'input:not([type="hidden"]):not([disabled]):not([tabindex="-1"])', - 'textarea:not([disabled]):not([tabindex="-1"])', - 'select:not([disabled]):not([tabindex="-1"])', - '[contenteditable=""]:not([tabindex="-1"])', - '[contenteditable="true"]:not([tabindex="-1"])', - ].join(','); -} - -function visibleFocusables(element: HTMLElement): HTMLElement[] { - return Array.from( - element.querySelectorAll(liftFocusableSelector()), - ).filter((candidate) => { - if (!candidate.isConnected) return false; - if (candidate.closest('[inert]')) return false; - const rects = candidate.getClientRects(); - return rects.length > 0 || candidate === document.activeElement; - }); -} - -function firstLiftFocusTarget(element: HTMLElement): HTMLElement | null { - const body = element.querySelector('.bx-lift__body') ?? element; - const keyboardModel = element.getAttribute('data-bx-lift-keyboard-model'); - if (keyboardModel === 'pick') { - const listbox = body.querySelector( - '[role="listbox"]:not([tabindex="-1"])', - ); - if (listbox) return listbox; - } - const autofocus = body.querySelector('[autofocus]'); - if (autofocus) return autofocus; - if ( - keyboardModel === 'edit-text' || - keyboardModel === 'edit-number' || - keyboardModel === 'compose' - ) { - const editor = body.querySelector(liftEditorSelector()); - if (editor) return editor; - } - return body.querySelector(liftFocusableSelector()); -} - -function focusLiftTarget(target: HTMLElement): void { - target.focus({ preventScroll: true }); - if (target instanceof HTMLInputElement) { - if ( - target.type === 'text' || - target.type === 'number' || - target.type === 'search' || - target.type === 'url' || - target.type === 'tel' || - target.type === 'email' || - target.type === 'password' - ) { - target.select(); - } - } else if (target instanceof HTMLTextAreaElement) { - target.select(); - } -} - -type ReroutedKeyboardEvent = KeyboardEvent & { - __boxelLiftKeyboardRerouted?: true; -}; - -function isPlainTextKey(event: KeyboardEvent): boolean { - return ( - event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey - ); -} - -function isPickerNavigationKey(event: KeyboardEvent): boolean { - return ( - event.key === 'ArrowDown' || - event.key === 'ArrowUp' || - event.key === 'Home' || - event.key === 'End' || - event.key === 'Enter' || - event.key === 'Tab' || - event.key === ' ' || - event.key === 'Spacebar' || - isPlainTextKey(event) - ); -} - -function isEditingKey(event: KeyboardEvent): boolean { - if (event.metaKey || event.ctrlKey || event.altKey) return false; - return ( - event.key === 'Enter' || - event.key === 'Tab' || - event.key.startsWith('Arrow') || - event.key === 'Home' || - event.key === 'End' || - event.key === 'PageUp' || - event.key === 'PageDown' || - event.key === 'Backspace' || - event.key === 'Delete' || - isPlainTextKey(event) - ); -} - -function liftKeyboardModelOwnsEvent( - element: HTMLElement, - event: KeyboardEvent, -): boolean { - const keyboardModel = element.getAttribute('data-bx-lift-keyboard-model'); - if (keyboardModel === 'pick') return isPickerNavigationKey(event); - if ( - keyboardModel === 'edit-text' || - keyboardModel === 'edit-number' || - keyboardModel === 'compose' - ) { - return isEditingKey(event); - } - return false; -} - -function topmostKeyboardLift(): HTMLElement | null { - const lifts = Array.from( - document.querySelectorAll( - '[data-bx-lift][data-bx-lift-keyboard-lock="true"]', - ), - ); - return ( - lifts.sort((a, b) => { - const za = Number(a.dataset['surfaceLayerZ'] ?? 0); - const zb = Number(b.dataset['surfaceLayerZ'] ?? 0); - return zb - za; - })[0] ?? null - ); -} - -function cloneKeyboardEvent(event: KeyboardEvent): ReroutedKeyboardEvent { - const next = new KeyboardEvent(event.type, { - key: event.key, - code: event.code, - location: event.location, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - metaKey: event.metaKey, - shiftKey: event.shiftKey, - repeat: event.repeat, - isComposing: event.isComposing, - bubbles: true, - cancelable: true, - }) as ReroutedKeyboardEvent; - next.__boxelLiftKeyboardRerouted = true; - return next; -} - -const liftFocusModifier = modifier( - ( - element: HTMLElement, - [focusToken]: [string | number | undefined], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - const initial = document.activeElement as HTMLElement | null; - const previouslyFocused = - initial && initial !== document.body ? initial : null; - const token = focusToken === undefined ? undefined : String(focusToken); - let frame = 0; - let attempts = 0; - const focusWhenReady = (): void => { - if (token && focusedLiftTokens.has(token)) return; - // Pick model: prefer the LISTBOX (Spotlight idiom). Compose - // model: prefer the editor's own input (calendar's date input, - // formula builder's expression box). Other models: first - // focusable wins. - const target = firstLiftFocusTarget(element); - if (!target) { - if (attempts++ < 4) { - frame = requestAnimationFrame(focusWhenReady); - } - return; - } - focusLiftTarget(target); - if (token) focusedLiftTokens.add(token); - }; - frame = requestAnimationFrame(focusWhenReady); - const anchorSelector = element.getAttribute('data-bx-lift-anchor-selector'); - return (): void => { - cancelAnimationFrame(frame); - const active = document.activeElement as HTMLElement | null; - if (active?.closest('[data-bx-lift]')) return; - const focusEscaped = - active !== null && - active !== document.body && - !element.contains(active); - if (focusEscaped) return; - const isFocusable = (el: HTMLElement): boolean => { - if (el.hasAttribute('disabled')) return false; - const tag = el.tagName; - if ( - tag === 'INPUT' || - tag === 'TEXTAREA' || - tag === 'SELECT' || - tag === 'BUTTON' || - tag === 'A' - ) { - return true; - } - if (el.hasAttribute('contenteditable')) return true; - const ti = el.getAttribute('tabindex'); - if (ti !== null && ti !== '-1') return true; - return false; - }; - const findFocusable = (start: HTMLElement | null): HTMLElement | null => { - let cur: HTMLElement | null = start; - while (cur && cur !== document.body) { - if (isFocusable(cur)) return cur; - cur = cur.parentElement; - } - return start; - }; - let restoreTo: HTMLElement | null = null; - if (previouslyFocused && document.contains(previouslyFocused)) { - restoreTo = isFocusable(previouslyFocused) - ? previouslyFocused - : findFocusable(previouslyFocused); - } - if (!restoreTo && anchorSelector) { - const anchor = document.querySelector(anchorSelector); - restoreTo = findFocusable(anchor); - } - if (!restoreTo) return; - restoreTo.focus(); - setTimeout(() => { - if (restoreTo && document.contains(restoreTo)) restoreTo.focus(); - }, 0); - }; - }, -); - -/** Delegates stale-focus keyboard events into the active lift body. - * - * This is the engine-level version of the old grid-demo pattern: - * while an edit/tools lift is open, Arrow/Enter/Space/type-ahead - * belong to the lifted control, even if the browser still reports - * DOM focus on the source cell or parent grid. The lift focuses its - * negotiated target (`keyboardModel="pick"` prefers listbox; - * text/number/compose prefer the editor) and re-dispatches a cloned - * key event there. Host grids should see neither the stale event nor - * a parent navigation command. - */ -const delegateLiftKeyboardModifier = modifier( - ( - element: HTMLElement, - _positional: never[], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - - const onKeydown = (event: KeyboardEvent): void => { - const routed = event as ReroutedKeyboardEvent; - if (routed.__boxelLiftKeyboardRerouted) return; - if (event.defaultPrevented) return; - if (event.key === 'Escape') return; - if (topmostKeyboardLift() !== element) return; - - const target = event.target instanceof Element ? event.target : null; - const active = - document.activeElement instanceof Element - ? document.activeElement - : null; - // Treat ember-power-select's portal as logically inside the lift — - // keystrokes in its search/options must NOT be hijacked by the lift. - const insideLift = (node: Element): boolean => { - if (element.contains(node)) return true; - if (node.closest('.ember-basic-dropdown-content')) return true; - return false; - }; - if (target && insideLift(target)) return; - if (active && insideLift(active)) return; - if (!liftKeyboardModelOwnsEvent(element, event)) return; - - const delegateTarget = firstLiftFocusTarget(element); - if (!delegateTarget) return; - - event.preventDefault(); - event.stopImmediatePropagation(); - focusLiftTarget(delegateTarget); - delegateTarget.dispatchEvent(cloneKeyboardEvent(event)); - }; - - window.addEventListener('keydown', onKeydown, true); - return () => window.removeEventListener('keydown', onKeydown, true); - }, -); - -/** Keeps edit/plane lifts in control of DOM focus while they are open. - * - * Surface selection remains on the source coordinate; the lift owns - * the active editor. This mirrors grid/canvas lifted editing: Tab - * cycles inside the raised editor, and any programmatic focus steal - * back to the source is corrected on the next focusin/frame. */ -const trapLiftFocusModifier = modifier( - ( - element: HTMLElement, - _positional: never[], - { enabled = true }: { enabled?: boolean } = {}, - ) => { - if (!enabled) return; - - let lastFocused: HTMLElement | null = null; - let allowOutsideFocusUntil = 0; - - const focusFallback = (): void => { - requestAnimationFrame(() => { - if (!element.isConnected) return; - if ( - document.activeElement instanceof Element && - element.contains(document.activeElement) - ) { - return; - } - const target = - (lastFocused?.isConnected && element.contains(lastFocused) - ? lastFocused - : null) ?? firstLiftFocusTarget(element); - target?.focus({ preventScroll: true }); - }); - }; - - const onKeydown = (event: KeyboardEvent): void => { - if (event.key !== 'Tab') return; - const focusables = visibleFocusables(element); - if (focusables.length === 0) return; - - event.preventDefault(); - event.stopPropagation(); - - const active = document.activeElement as HTMLElement | null; - const currentIndex = active ? focusables.indexOf(active) : -1; - const nextIndex = - currentIndex === -1 - ? 0 - : event.shiftKey - ? (currentIndex - 1 + focusables.length) % focusables.length - : (currentIndex + 1) % focusables.length; - const next = focusables[nextIndex]; - if (!next) return; - lastFocused = next; - next.focus({ preventScroll: true }); - }; - - // Treat ember-power-select's portal (rendered at document.body) as - // logically inside the lift — its dropdown options sit outside our - // element subtree but represent interaction with our content. - const isInsideOrPortal = (target: Element): boolean => { - if (element.contains(target)) return true; - if (target.closest('.ember-basic-dropdown-content')) return true; - return false; - }; - - const onFocusin = (event: FocusEvent): void => { - const target = event.target; - if (!(target instanceof HTMLElement)) return; - if (isInsideOrPortal(target)) { - lastFocused = target; - return; - } - if (Date.now() < allowOutsideFocusUntil) return; - focusFallback(); - }; - - const onPointerdown = (event: PointerEvent): void => { - const target = event.target; - if (target instanceof Element && isInsideOrPortal(target)) return; - // Outside pointerdown is normally a dismiss gesture. Give the - // close path a short window so the trap does not fight the - // user's intentional click outside the lift. - allowOutsideFocusUntil = Date.now() + 250; - }; - - element.addEventListener('keydown', onKeydown, true); - window.addEventListener('focusin', onFocusin, true); - window.addEventListener('pointerdown', onPointerdown, true); - - return () => { - element.removeEventListener('keydown', onKeydown, true); - window.removeEventListener('focusin', onFocusin, true); - window.removeEventListener('pointerdown', onPointerdown, true); - }; - }, -); - -let nextLiftInstanceId = 0; - -const cleanupClosedLiftModifier = modifier( - (_element: HTMLElement, [open, instanceId]: [boolean, string]) => { - let frame = 0; - if (!open) { - frame = requestAnimationFrame(() => { - for (const stale of document.querySelectorAll( - `[data-bx-lift-instance="${instanceId}"]`, - )) { - stale.remove(); - } - }); - } - - return () => { - cancelAnimationFrame(frame); - }; - }, -); - -export default class Lift extends Component { - readonly instanceId = `bx-lift-${++nextLiftInstanceId}`; - @consume(SurfaceScopeContextName) declare inheritedScopeRelay: - | SurfaceScopeRelay - | undefined; - @consume(LadderContextName) declare inheritedLadder: FocusLadder | undefined; - @consume(SurfaceRuntimeContextName) declare inheritedRuntime: - | SurfaceRuntime - | undefined; - @consume(LiftContextName) declare inheritedLiftManager: - | LiftManager - | undefined; - @consume(ModeContextName) declare inheritedMode: - | 'use' - | 'change' - | 'inspect' - | undefined; - @consume(InspectContextName) declare inheritedInspect: boolean | undefined; - private localScopeRelay: SurfaceScopeRelay | undefined; - - get scopeRelay(): SurfaceScopeRelay { - let relay = this.localScopeRelay; - if (!relay || relay.parent !== this.inheritedScopeRelay) { - relay = createSurfaceScopeRelay(this.inheritedScopeRelay); - // eslint-disable-next-line ember/no-side-effects - this.localScopeRelay = relay; - } - return relay; - } - - // ─── arg defaults ─────────────────────────────────────────────── - - get portalTarget(): HTMLElement { - if (typeof document === 'undefined') { - throw new Error(' requires a browser document to portal into.'); - } - return document.body; - } - - get effectivePlacement(): Placement { - return this.args.placement ?? 'bottom-start'; - } - - get placementMode(): LiftPlacement { - return this.args.placementMode ?? 'attached'; - } - - get size(): LiftSize { - if (this.args.size) return this.args.size; - // Per-kind defaults when contract didn't specify. - if (this.args.kind === 'edit') return 'comfortable'; - if (this.args.kind === 'tools') return 'compact'; - return 'compact'; // details / preview - } - - get backdrop(): LiftBackdrop { - if (this.args.backdrop) return this.args.backdrop; - if (this.args.kind === 'edit') return 'blur'; - if (this.args.kind === 'tools') return 'none'; - return 'tint'; // details / preview - } - - get elevation(): LiftElevation { - if (this.args.elevation) return this.args.elevation; - if (this.placementMode === 'plane') return 'modal'; - if (this.args.kind === 'edit') return 'elevated'; - return 'raised'; - } - - get keyboardModel(): LiftKeyboardModel { - return this.args.keyboardModel ?? 'compose'; - } - - get isShadow(): boolean { - return this.placementMode === 'shadow'; - } - - get isPlane(): boolean { - return this.placementMode === 'plane'; - } - - get hasScrim(): boolean { - return this.backdrop === 'scrim'; - } - - /** Default autoFocus policy. */ - get shouldAutoFocus(): boolean { - if (this.args.autoFocus !== undefined) return this.args.autoFocus; - return this.args.kind !== 'details'; - } - - /** Edit lifts are popover-shaped but modal-like for focus. */ - get shouldTrapFocus(): boolean { - return this.args.kind === 'edit' || this.placementMode === 'plane'; - } - - get shouldDelegateKeyboard(): boolean { - return ( - this.args.kind === 'edit' || - this.args.kind === 'tools' || - this.placementMode === 'plane' - ); - } - - get layerTier(): SurfaceLayerTier { - if (this.args.layerTier) return this.args.layerTier; - if (this.placementMode === 'plane' || this.elevation === 'modal') { - return 'modal'; - } - if (this.placementMode === 'shadow') return 'cell-lift'; - return 'popover'; - } - - /** Inline style string for the lift root. Carries the optional - * `relativeScale` arg as a `transform: scale(...)` with origin - * pinned to the lift's top-left corner. - * - * WHY NOT CSS `zoom`: the CSS `zoom` property scales ALL element - * dimensions — INCLUDING positional `top` / `left` written by the - * anchored positioning modifier. So a lift with `top: 200px` and - * `zoom: 0.8` actually paints at `top: 160px`, jumping it away - * from its anchor. That was the "position out of whack" bug. - * - * WHY transform + top-left origin: the anchor modifier uses Floating UI's - * `computePosition` which already reads `getBoundingClientRect` - * (which RETURNS post-transform coords), so the scaled lift's - * apparent box is what the positioner sizes against. With - * `transform-origin: top left`, the visual top-left of the scaled - * box stays exactly at the `top: y; left: x;` point — for - * `bottom-start` placement that's the cell's bottom-left, which - * is what we want. - * - * Plane placement gets NO scale (plane is a viewport modal, not - * an anchored surface; it always renders at viewport scale). */ - get rootStyle(): string { - const z = this.args.relativeScale; - if (z === undefined || z === 1) return ''; - // Only attached lifts honor relativeScale. Plane is a viewport - // modal — not anchored to scalable host content. Shadow already - // overlays the source cell which is itself in host coords (the - // shadowAnchor modifier reads cell's screen bbox, which already - // reflects canvas zoom), so an extra scale would double-apply. - if (this.placementMode !== 'attached') return ''; - // Hard safety clamp — the damped curve helper already produces - // a tight range (0.7..1.5 typical), but a host that does its own - // math could send something extreme. We don't want layout to - // explode either way. - const clamped = Math.max(0.4, Math.min(2.5, z)); - return `transform: scale(${clamped}); transform-origin: top left;`; - } - - // ─── classes ──────────────────────────────────────────────────── - - /** Composite class for the lift root. Includes kind + placement - * + size + backdrop + elevation. CSS reads these as orthogonal - * modifiers (see styles below). */ - get liftClass(): string { - return [ - 'bx-lift', - `bx-lift--${this.args.kind}`, - `bx-lift--placement-${this.placementMode}`, - `bx-lift--size-${this.size}`, - `bx-lift--backdrop-${this.backdrop}`, - `bx-lift--elevation-${this.elevation}`, - ].join(' '); - } - - // ─── escalation glyph ────────────────────────────────────────── - - /** Other kinds the user can escalate to (filtered to exclude - * the current one). When empty, no escalation chrome renders. */ - get escalationTargets(): LiftKind[] { - return (this.args.canEscalateTo ?? []).filter((k) => k !== this.args.kind); - } - - get hasEscalation(): boolean { - return this.escalationTargets.length > 0 && this.args.onEscalate != null; - } - - /** Glyph for the corner escalation button. When escalation has - * exactly one target, use that target's glyph. Otherwise (rare, - * but supported), use a generic kebab. */ - get escalationGlyph(): string { - const targets = this.escalationTargets; - const only = targets[0]; - if (targets.length === 1 && only) return this.kindGlyph(only); - return '⋯'; - } - - /** Aria-label for the corner escalation button. */ - get escalationLabel(): string { - const targets = this.escalationTargets; - const only = targets[0]; - if (targets.length === 1 && only) { - return `Switch to ${this.kindLabel(only)}`; - } - return 'Switch lift mode'; - } - - /** Default action when the user clicks the corner glyph. With - * exactly one escalation target, fire that. Otherwise rotate - * through targets. */ - fireEscalateNext = (): void => { - const targets = this.escalationTargets; - const first = targets[0]; - if (targets.length === 0) return; - if (targets.length === 1 && first) { - this.args.onEscalate?.(first); - return; - } - // Multi-target — pick the first that ISN'T the current kind. - if (first) this.args.onEscalate?.(first); - }; - - kindLabel(kind: LiftKind): string { - switch (kind) { - case 'details': - return 'Details'; - case 'preview': - return 'Preview'; - case 'edit': - return 'Edit'; - case 'tools': - return 'Tools'; - } - } - - kindGlyph(kind: LiftKind): string { - switch (kind) { - case 'details': - return 'ⓘ'; - case 'preview': - return '⊡'; - case 'edit': - return '✎'; - case 'tools': - return '⋯'; - } - } - - /** Scrim click — fires onDismiss if provided. Bound here so the - * template can wire it without `(fn ...)` plumbing. */ - handleScrimClick = (): void => { - this.args.onDismiss?.(); - }; - - // Modifiers exposed on instance for Glint strict mode. - anchoredLift = anchoredLift; - shadowAnchor = shadowAnchor; - liftFocus = liftFocusModifier; - trapLiftFocus = trapLiftFocusModifier; - delegateLiftKeyboard = delegateLiftKeyboardModifier; - dismissOnOutside = dismissOnOutside; - allocateLiftLayer = allocateLiftLayer; - liftSurfaceRoot = liftSurfaceRoot; - cleanupClosedLift = cleanupClosedLiftModifier; - - -} diff --git a/boxel-surface/src/components/lift/index.gts b/boxel-surface/src/components/lift/index.gts deleted file mode 100644 index 600069fa..00000000 --- a/boxel-surface/src/components/lift/index.gts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from '../lift.gts'; -export * from '../lift.gts'; diff --git a/boxel-surface/src/components/number-cell.gts b/boxel-surface/src/components/number-cell.gts deleted file mode 100644 index ec1901e8..00000000 --- a/boxel-surface/src/components/number-cell.gts +++ /dev/null @@ -1,155 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { consume } from 'ember-provide-consume-context'; - -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; -import type { - CellValidationState, - FociNodePolicy, -} from './surface-component.gts'; -import { Cell } from './surface-component.gts'; - -export interface NumberCellSignature { - Args: { - value?: number | string; - placeholder?: string; - state?: CellValidationState; - disabled?: boolean; - readonly?: boolean; - min?: number; - max?: number; - step?: number | string; - prefix?: string; - suffix?: string; - onInput?: (value: string) => void; - runtimePolicy?: FociNodePolicy; - }; - Element: HTMLElement; -} - -export default class NumberCell extends Component { - @consume(FormFieldContextName) declare inheritedFormField: - | FormFieldContext - | undefined; - - get value(): string { - return this.args.value === undefined ? '' : String(this.args.value); - } - - get isReadonly(): boolean { - return this.args.readonly ?? this.inheritedFormField?.readonly ?? false; - } - - get isDisabled(): boolean { - return this.args.disabled ?? this.inheritedFormField?.disabled ?? false; - } - - @action - handleInput(event: Event): void { - this.args.onInput?.((event.target as HTMLInputElement).value); - } - - -} diff --git a/boxel-surface/src/components/surface-component.gts b/boxel-surface/src/components/surface-component.gts deleted file mode 100644 index 030b25e4..00000000 --- a/boxel-surface/src/components/surface-component.gts +++ /dev/null @@ -1,2699 +0,0 @@ -import Component from '@glimmer/component'; -import { guidFor } from '@ember/object/internals'; -import { cached, tracked } from '@glimmer/tracking'; -import { on } from '@ember/modifier'; -import type Owner from '@ember/owner'; -import { modifier } from 'ember-modifier'; -import { consume, provide } from 'ember-provide-consume-context'; -import ContextProvider from 'ember-provide-consume-context/components/context-provider'; - -import { createFocusLadder } from '../focus-ladder.ts'; -import type { - FocusLadder, - LadderSurface, - Target, - TargetScope, -} from '../focus-ladder.ts'; -export type { Target } from '../focus-ladder.ts'; -import type { - FociEditPolicy, - FociGridCoordinate, - FociKeyboardPolicy, - FociMovementPolicy, - FociNodePolicy, - FociPointerPolicy, - FociPreset, - FociPresetAspect, - FociSelectionPolicy, - FociTraversalModel, - FociTraversalPolicy, -} from '../foci-store.ts'; -export type { FociNodePolicy } from '../foci-store.ts'; -import { - createSurfaceRuntime, - type SurfaceRuntime, -} from '../surface-runtime.ts'; -import { - createSurfaceScopeRelay, - SurfaceScopeContextName, - type SurfaceScopeRelay, -} from '../scope-relay.ts'; -import surfaceNode from '../modifiers/node.ts'; -import surfaceRoot from '../modifiers/root.ts'; -import surfaceInlineEdit from '../modifiers/inline-edit.ts'; -import type { InlineEditOptions } from '../modifiers/inline-edit.ts'; -import surfaceScopeRelay from '../modifiers/scope-relay.ts'; -import surfaceCoordinateDebugger from '../modifiers/coordinate-debugger.ts'; -import type { CoordinateDebugView } from '../modifiers/coordinate-debugger.ts'; -import Lift from './lift.gts'; -import { - LadderContextName, - SurfaceRuntimeContextName, - ParentIdContextName, - ParentContextName, - DemoContextName, - ModeContextName, - InspectContextName, - PathContextName, - ChangeRouteContextName, - CoordinateSpaceContextName, -} from '../surface-contexts.ts'; -export { - LadderContextName, - SurfaceRuntimeContextName, - ParentIdContextName, - ParentContextName, - DemoContextName, - ModeContextName, - InspectContextName, - PathContextName, - ChangeRouteContextName, - CoordinateSpaceContextName, -} from '../surface-contexts.ts'; -import { createLiftManager, LiftContextName } from '../lift-edges.ts'; -import type { - SurfaceLiftEdgeInput, - LiftEdges, - LiftManager, - LiftResolver, - LiftTargetComponent, - LiftTargetContext, -} from '../lift-edges.ts'; -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; - -type SurfaceTag = 'article' | 'aside' | 'button' | 'div' | 'nav' | 'section'; - -export type DemoMode = boolean | string; -export type KeyboardMode = boolean | 'surface-tree' | 'manual' | 'none'; -export type Mode = 'use' | 'change' | 'inspect'; -export type Posture = 'use' | 'compose'; -export type ChangeRoute = 'auto' | 'inline' | 'lifted'; -export type Role = 'content' | 'structure' | 'control'; -export type DirectiveScope = 'self' | 'children' | 'descendants' | 'subtree'; -export type CellSurface = 'form' | 'grid' | 'canvas' | 'scene'; -export type CellValidationState = - | 'none' - | 'valid' - | 'invalid' - | 'loading' - | 'initial'; -export type CellState = - | 'idle' - | 'hovered' - | 'active' - | 'editing-inline' - | 'lift-host' - | 'drag-source' - | 'drop-target'; -export type SurfaceCoordinateSource = - | 'explicit' - | 'identity' - | 'context' - | 'generated'; -export type Identity = - | string - | number - | { id: string | number } - | { '@id': string | number }; -export type IdentityPart = string | number | boolean; -export type Path = IdentityPart[]; -export type CoordinateSpace = string; -export type LocalCoordinate = - | string - | number - | boolean - | null - | Record - | unknown[]; - -export interface CoordinateSpaceContext { - surface: LadderSurface; - id: string; - schema: CoordinateSpace; -} - -function defaultCoordinateSpaceSchema(surface: LadderSurface): CoordinateSpace { - switch (surface) { - case 'space': - return 'surface-network'; - case 'layout': - return 'layout'; - case 'canvas': - return 'canvas-plane'; - case 'scene': - return 'scene-world'; - case 'grid': - return 'range-grid'; - case 'row': - return 'range-row'; - case 'scroll': - return 'document-flow'; - case 'flow': - return 'ordered-list'; - case 'outline': - return 'outline-tree'; - case 'connection': - return 'connection-path'; - case 'frame': - return 'fitted-rect'; - case 'pane': - return 'pane-slot'; - case 'plane': - return 'plane-layer'; - case 'cell': - return 'cell-value'; - case 'run': - return 'text'; - case 'unit': - return 'token'; - } -} - -export interface ChangePreference { - inline?: boolean | InlineEditOptions; - lift?: false | SurfaceLiftEdgeInput; -} - -export type ChangeInput = boolean | ChangePreference; - -let counters: Record = {}; - -export function nextSurfaceId(surface: string): string { - counters[surface] = (counters[surface] ?? 0) + 1; - return `${surface}:${counters[surface]}`; -} - -export function nextScopedSurfaceId( - parentId: string | undefined, - surface: string, -): string { - if (!parentId) return nextSurfaceId(surface); - const key = `${parentId}/${surface}`; - counters[key] = (counters[key] ?? 0) + 1; - return `${parentId}/${surface}:${counters[key]}`; -} - -function identityValue(identity: Identity): string | number { - if (typeof identity === 'object') { - if ('id' in identity) { - return identity.id; - } - - return identity['@id']; - } - - return identity; -} - -function coordinatePartAttribute(value: unknown): string | undefined { - if (value === undefined || value === null) { - return undefined; - } - - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return String(value); - } - - try { - return JSON.stringify(value); - } catch { - return String(value); - } -} - -function encodeIdPart(part: IdentityPart): string { - return encodeURIComponent(String(part)); -} - -export function surfaceId( - surface: string, - identity: Identity, - ...parts: IdentityPart[] -): string { - return [ - surface, - encodeIdPart(identityValue(identity)), - ...parts.map(encodeIdPart), - ].join(':'); -} - -export function surfaceFocusKey( - identity: Identity, - ...parts: IdentityPart[] -): string { - return [ - encodeIdPart(identityValue(identity)), - ...parts.map(encodeIdPart), - ].join(':'); -} - -export function surfaceIdFromPath(surface: string, path: Path): string { - return [surface, ...path.map(encodeIdPart)].join(':'); -} - -export function surfaceFocusKeyFromPath(path: Path): string { - return path.map(encodeIdPart).join(':'); -} - -function normalizeSurfacePath(identity: Identity, parts: IdentityPart[]): Path { - return [identityValue(identity), ...parts]; -} - -function normalizeChange( - change: ChangeInput | undefined, -): ChangePreference | undefined { - if (!change) return undefined; - return change === true ? {} : change; -} - -function modeForPosture(posture: Posture | undefined): Mode | undefined { - if (posture === undefined) return undefined; - return posture === 'compose' ? 'change' : 'use'; -} - -function inlineOptions( - change: ChangePreference | undefined, -): InlineEditOptions | undefined { - if (!change?.inline) return undefined; - return change.inline === true ? {} : change.inline; -} - -function liftEdgesWithChange( - base: LiftEdges | undefined, - change: ChangePreference | undefined, - useChangeLift: boolean, -): LiftEdges | undefined { - if (!useChangeLift || !change || change.lift === false) return base; - return { - ...(base ?? {}), - edit: change.lift ?? true, - }; -} - -export interface SurfaceComponentSignature { - Args: { - id?: string; - focusKey?: string; - surfacePath?: Path; - /** Presentation/interaction coordinate-space id. This is not the persistent record. */ - space?: Identity; - /** Reserved for CardDef/FieldDef integration. Ignored by the Surface runtime. */ - model?: unknown; - /** Reserved for CardDef/FieldDef integration. Ignored by the Surface runtime. */ - field?: unknown; - /** Reserved for CardDef/FieldDef integration. Ignored by the Surface runtime. */ - fields?: unknown; - /** Optional coordinate-space schema when the surface default is not specific enough. */ - schema?: CoordinateSpace; - /** Local coordinate inside the nearest parent coordinate space. */ - coord?: LocalCoordinate; - /** Compatibility alias for @space. Prefer @space in V3 authoring. */ - identity?: Identity; - key?: IdentityPart | IdentityPart[]; - identityPart?: IdentityPart | IdentityPart[]; - tag?: SurfaceTag; - inline?: boolean; - role?: Role; - /** Cell chrome surface override. Used by Cell only. */ - surface?: CellSurface; - /** Cell validation state. Used by Cell only. */ - state?: CellValidationState; - disabled?: boolean; - readonly?: boolean; - bottomTreatment?: 'flat' | 'rounded'; - chained?: boolean; - pattern?: string; - /** Runtime preset. Package wrappers should pass their behavior preset here. */ - preset?: FociPreset; - /** Extra preset aspects for the runtime compiler. */ - aspects?: FociPresetAspect[]; - /** Full low-level runtime policy escape hatch. Prefer preset/aspects first. */ - runtimePolicy?: FociNodePolicy; - /** Optional grid coordinate used by engine-owned sheet movement/ranges. */ - grid?: FociGridCoordinate; - gridRow?: number; - gridCol?: number; - runtimeTraversal?: FociTraversalPolicy; - runtimeTraversalModel?: FociTraversalModel; - runtimeSelection?: FociSelectionPolicy; - runtimeKeyboard?: FociKeyboardPolicy; - runtimeMovement?: FociMovementPolicy; - runtimePointer?: FociPointerPolicy; - runtimeEdit?: FociEditPolicy; - accepts?: string[]; - payloadType?: string; - scope?: DirectiveScope; - depth?: number | 'all'; - expanded?: boolean; - onSelect?: (event: Event) => void; - onActivate?: (event: Event) => void; - scrollOnSelect?: boolean; - scrollTarget?: string; - scrollAnchor?: string; - hoverSignal?: string; - hoverAnchor?: string; - onExpand?: (event: Event) => void; - onCollapse?: (event: Event) => void; - demo?: DemoMode; - /** V3 authoring posture. Prefer this over @mode for use/compose surfaces. */ - posture?: Posture; - /** Low-level runtime mode. Kept for compatibility; prefer @posture plus @inspect in V3. */ - mode?: Mode; - /** Inspection overlay. Independent from use/change posture. Defaults true when @mode='inspect'. */ - inspect?: boolean; - changeRoute?: ChangeRoute; - target?: Target; - targetScope?: TargetScope; - /** Compatibility alias for @schema. Prefer @space plus optional @schema in V3 authoring. */ - coordinateSpace?: CoordinateSpace; - /** Compatibility alias for @coord. Prefer @coord in V3 authoring. */ - at?: LocalCoordinate; - change?: ChangeInput; - lift?: LiftEdges; - liftData?: unknown; - inlineEdit?: boolean; - editValue?: string; - editLabel?: string; - editMultiline?: boolean; - onEditInput?: (value: string, event: InputEvent) => void; - }; - Blocks: { - default: []; - pre: []; - post: []; - }; - Element: HTMLElement; -} - -export abstract class SurfaceComponent extends Component { - private generatedId: string | undefined; - - @consume(LadderContextName) declare inheritedLadder: FocusLadder | undefined; - @consume(SurfaceRuntimeContextName) declare inheritedSurfaceRuntime: - | SurfaceRuntime - | undefined; - @consume(ParentIdContextName) declare inheritedParentId: string | undefined; - @consume(ParentContextName) declare inheritedParentSurface: - | LadderSurface - | undefined; - @consume(DemoContextName) declare inheritedDemo: DemoMode | undefined; - @consume(ModeContextName) declare inheritedMode: Mode | undefined; - @consume(InspectContextName) declare inheritedInspect: boolean | undefined; - @consume(ChangeRouteContextName) declare inheritedChangeRoute: - | ChangeRoute - | undefined; - @consume(PathContextName) declare inheritedSurfacePath: Path | undefined; - @consume(CoordinateSpaceContextName) declare inheritedCoordinateSpace: - | CoordinateSpaceContext - | undefined; - @consume(LiftContextName) declare inheritedLiftManager: - | LiftManager - | undefined; - @consume(SurfaceScopeContextName) declare inheritedScopeRelay: - | SurfaceScopeRelay - | undefined; - - private localScopeRelay: SurfaceScopeRelay | undefined; - - abstract get surface(): LadderSurface; - - get scopeRelay(): SurfaceScopeRelay { - let relay = this.localScopeRelay; - if (!relay || relay.parent !== this.inheritedScopeRelay) { - relay = createSurfaceScopeRelay(this.inheritedScopeRelay); - // eslint-disable-next-line ember/no-side-effects - this.localScopeRelay = relay; - } - return relay; - } - - get spaceIdentity(): Identity | undefined { - return this.args.space ?? this.args.identity; - } - - get localCoordinate(): LocalCoordinate | undefined { - return this.args.coord ?? this.args.at; - } - - @cached - get coordinateSchema(): CoordinateSpace | undefined { - return ( - this.args.schema ?? - this.args.coordinateSpace ?? - (this.spaceIdentity !== undefined - ? defaultCoordinateSpaceSchema(this.surface) - : undefined) - ); - } - - @cached - get id(): string { - if (this.args.id) { - return this.args.id; - } - - if (this.usesAnonymousLeafGeneratedId) { - // eslint-disable-next-line ember/no-side-effects - this.generatedId ??= nextScopedSurfaceId( - this.inheritedParentId, - this.surface, - ); - return this.generatedId; - } - - if (this.surfacePath !== undefined && !this.usesContextScopedGeneratedId) { - return surfaceIdFromPath(this.surface, this.surfacePath); - } - - if (this.spaceIdentity !== undefined) { - return surfaceId(this.surface, this.spaceIdentity, ...this.keyParts); - } - - // eslint-disable-next-line ember/no-side-effects - this.generatedId ??= this.usesContextScopedGeneratedId - ? nextScopedSurfaceId(this.inheritedParentId, this.surface) - : nextSurfaceId(this.surface); - return this.generatedId; - } - - @cached - get focusKey(): string | undefined { - if (this.args.focusKey) { - return this.args.focusKey; - } - - if (this.surfacePath !== undefined) { - return surfaceFocusKeyFromPath(this.surfacePath); - } - - if (this.spaceIdentity !== undefined) { - return surfaceFocusKey(this.spaceIdentity, ...this.keyParts); - } - - return undefined; - } - - get pathAttribute(): string | undefined { - return this.surfacePath !== undefined - ? surfaceFocusKeyFromPath(this.surfacePath) - : undefined; - } - - @cached - get coordinate(): string | undefined { - if ( - this.args.coord !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - let local = coordinatePartAttribute(this.args.coord); - return local !== undefined - ? `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]:${local}` - : `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]`; - } - - if (this.coordinateSchema !== undefined) { - let local = coordinatePartAttribute(this.localCoordinate); - let spaceId = this.coordinateSpaceId; - return local !== undefined - ? `${spaceId}[${this.coordinateSchema}]:${local}` - : `${spaceId}[${this.coordinateSchema}]`; - } - - if ( - this.localCoordinate !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - let local = coordinatePartAttribute(this.localCoordinate); - return local !== undefined - ? `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]:${local}` - : `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]`; - } - - return this.pathAttribute ?? this.args.focusKey; - } - - get coordinateSpaceAttribute(): string | undefined { - if (this.coordinateSchema !== undefined) { - return this.coordinateSchema; - } - - if (this.localCoordinate !== undefined) { - return this.inheritedCoordinateSpace?.schema; - } - - return undefined; - } - - get localCoordinateAttribute(): string | undefined { - return coordinatePartAttribute(this.localCoordinate); - } - - get directiveDepthAttribute(): string | undefined { - return this.args.depth === undefined ? undefined : String(this.args.depth); - } - - get expandableAttribute(): string | undefined { - return this.args.expanded === undefined ? undefined : 'true'; - } - - get expandedAttribute(): string | undefined { - return this.args.expanded === undefined - ? undefined - : String(this.args.expanded); - } - - @cached - get coordinateSpaceId(): string { - if ( - this.coordinateSchema === undefined && - this.localCoordinate !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - return this.inheritedCoordinateSpace.id; - } - - return this.focusKey ?? this.id; - } - - get providedCoordinateSpace(): CoordinateSpaceContext | undefined { - if (this.coordinateSchema !== undefined) { - return { - surface: this.surface, - id: this.focusKey ?? this.id, - schema: this.coordinateSchema, - }; - } - - return this.inheritedCoordinateSpace; - } - - get coordinateSource(): SurfaceCoordinateSource { - if ( - this.args.surfacePath !== undefined || - this.args.focusKey !== undefined || - this.args.id !== undefined || - this.args.space !== undefined || - this.args.schema !== undefined || - this.args.coord !== undefined || - this.args.coordinateSpace !== undefined || - this.args.at !== undefined - ) { - return 'explicit'; - } - - if (this.spaceIdentity !== undefined) { - return 'identity'; - } - - if (this.inheritedSurfacePath !== undefined) { - return 'context'; - } - - return 'generated'; - } - - get usesGeneratedId(): boolean { - return ( - this.usesAnonymousLeafGeneratedId || - this.usesContextScopedGeneratedId || - (this.args.id === undefined && - this.spaceIdentity === undefined && - this.surfacePath === undefined) - ); - } - - get usesAnonymousLeafGeneratedId(): boolean { - return ( - this.args.id === undefined && - (this.surface === 'run' || this.surface === 'unit') - ); - } - - get usesContextScopedGeneratedId(): boolean { - return ( - this.args.id === undefined && - this.args.surfacePath === undefined && - this.spaceIdentity === undefined && - this.keyParts.length === 0 && - this.inheritedSurfacePath !== undefined - ); - } - - get keyParts(): IdentityPart[] { - if (Array.isArray(this.args.key)) { - return this.args.key; - } - - if (this.args.key !== undefined) { - return [this.args.key]; - } - - if (Array.isArray(this.args.identityPart)) { - return this.args.identityPart; - } - - if (this.args.identityPart !== undefined) { - return [this.args.identityPart]; - } - - return []; - } - - get surfacePath(): Path | undefined { - if (this.args.surfacePath !== undefined) { - return this.args.surfacePath; - } - - if (this.spaceIdentity !== undefined) { - return normalizeSurfacePath(this.spaceIdentity, this.keyParts); - } - - if (this.inheritedSurfacePath !== undefined) { - return [...this.inheritedSurfacePath, ...this.keyParts]; - } - - return undefined; - } - - get ladder(): FocusLadder | undefined { - return this.inheritedLadder; - } - - get runtime(): SurfaceRuntime | undefined { - return this.inheritedSurfaceRuntime; - } - - @cached - get runtimeGridCoordinate(): FociGridCoordinate | undefined { - if (this.args.grid) return this.args.grid; - if (this.args.gridRow === undefined || this.args.gridCol === undefined) { - return undefined; - } - return { - row: this.args.gridRow, - col: this.args.gridCol, - }; - } - - @cached - get runtimePolicy(): FociNodePolicy | undefined { - const policy: FociNodePolicy = { - ...(this.args.runtimePolicy ?? {}), - }; - if (this.args.preset !== undefined) policy.preset = this.args.preset; - if (this.args.aspects !== undefined) policy.aspects = this.args.aspects; - if (this.args.runtimeTraversal !== undefined) { - policy.traversal = this.args.runtimeTraversal; - } - if (this.args.runtimeTraversalModel !== undefined) { - policy.traversalModel = this.args.runtimeTraversalModel; - } - if (this.args.runtimeSelection !== undefined) { - policy.selection = this.args.runtimeSelection; - } - if (this.args.runtimeKeyboard !== undefined) { - policy.keyboard = this.args.runtimeKeyboard; - } - if (this.args.runtimeMovement !== undefined) { - policy.movement = this.args.runtimeMovement; - } - if (this.args.runtimePointer !== undefined) { - policy.pointer = this.args.runtimePointer; - } - if (this.args.runtimeEdit !== undefined) { - policy.edit = this.args.runtimeEdit; - } else if (this.changeUsesInline) { - policy.edit = 'inline'; - } else if (this.changeUsesLift) { - policy.edit = 'lifted'; - } - if (this.args.accepts !== undefined) policy.accepts = this.args.accepts; - if (this.args.payloadType !== undefined) { - policy.payloadType = this.args.payloadType; - } - - return Object.keys(policy).length > 0 ? policy : undefined; - } - - get parentId(): string | undefined { - return this.inheritedParentId; - } - - get demo(): DemoMode { - return this.args.demo ?? this.inheritedDemo ?? false; - } - - get mode(): Mode { - return ( - this.args.mode ?? - modeForPosture(this.args.posture) ?? - this.inheritedMode ?? - 'use' - ); - } - - get explicitModeAttribute(): Mode | undefined { - return this.args.mode ?? modeForPosture(this.args.posture); - } - - get inspect(): boolean { - return ( - this.args.inspect ?? this.inheritedInspect ?? this.mode === 'inspect' - ); - } - - get inspectAttribute(): string { - return String(this.inspect); - } - - get explicitInspectAttribute(): string | undefined { - return this.args.inspect === undefined - ? undefined - : String(this.args.inspect); - } - - get changeRoute(): ChangeRoute { - return this.args.changeRoute ?? this.inheritedChangeRoute ?? 'auto'; - } - - get tag(): SurfaceTag { - return this.args.tag ?? 'div'; - } - - get inline(): boolean { - return this.args.inline ?? false; - } - - get liftManager(): LiftManager | undefined { - return this.inheritedLiftManager; - } - - get activeLiftSourceId(): string | undefined { - return this.liftManager?.activeSourceId; - } - - get activeLiftTargetId(): string | undefined { - return this.liftManager?.activeTargetId; - } - - get activeLiftKind(): string | undefined { - return this.liftManager?.kind; - } - - get activeLiftFocusToken(): number | undefined { - return this.liftManager?.focusToken; - } - - get changePreference(): ChangePreference | undefined { - return normalizeChange(this.args.change); - } - - get changeInlineOptions(): InlineEditOptions | undefined { - return inlineOptions(this.changePreference); - } - - get changeUsesInline(): boolean { - return this.changeInlineOptions !== undefined; - } - - get changeUsesLift(): boolean { - return this.changePreference !== undefined; - } - - get liftEdges(): LiftEdges | undefined { - return liftEdgesWithChange( - this.args.lift, - this.changePreference, - this.changeUsesLift, - ); - } - - get inlineEditEnabled(): boolean { - return this.args.inlineEdit ?? this.changeUsesInline; - } - - get inlineEditActivation(): 'always' | 'change-inline' { - return this.args.inlineEdit === undefined && this.changeUsesInline - ? 'change-inline' - : 'always'; - } - - get inlineEditValue(): string | undefined { - return this.changeInlineOptions?.value ?? this.args.editValue; - } - - get inlineEditLabel(): string | undefined { - return this.changeInlineOptions?.label ?? this.args.editLabel; - } - - get inlineEditMultiline(): boolean | undefined { - return this.changeInlineOptions?.multiline ?? this.args.editMultiline; - } - - get inlineEditInput(): - | ((value: string, event: InputEvent) => void) - | undefined { - return this.changeInlineOptions?.onInput ?? this.args.onEditInput; - } - - @provide(ParentIdContextName) - get providedParentId(): string { - return this.id; - } - - @provide(ParentContextName) - get providedParentSurface(): LadderSurface { - return this.surface; - } - - @provide(DemoContextName) - get providedDemo(): DemoMode { - return this.demo; - } - - @provide(ModeContextName) - get providedMode(): Mode { - return this.mode; - } - - @provide(InspectContextName) - get providedInspect(): boolean { - return this.inspect; - } - - @provide(ChangeRouteContextName) - get providedChangeRoute(): ChangeRoute { - return this.changeRoute; - } - - @provide(PathContextName) - get providedSurfacePath(): Path | undefined { - return this.surfacePath; - } - - @provide(CoordinateSpaceContextName) - get providedCoordinateSpaceContext(): CoordinateSpaceContext | undefined { - return this.providedCoordinateSpace; - } - - @provide(SurfaceScopeContextName) - get providedScopeRelay(): SurfaceScopeRelay { - return this.scopeRelay; - } - - get isArticle(): boolean { - return this.tag === 'article'; - } - - get isAside(): boolean { - return this.tag === 'aside'; - } - - get isNav(): boolean { - return this.tag === 'nav'; - } - - get isButton(): boolean { - return this.tag === 'button'; - } - - get isSection(): boolean { - return this.tag === 'section'; - } - - -} - -export interface EnvironmentSignature extends SurfaceComponentSignature { - Args: SurfaceComponentSignature['Args'] & { - ladder?: FocusLadder; - keyboard?: KeyboardMode; - mode?: Mode; - liftResolver?: LiftResolver; - coordinateDebug?: boolean; - coordinateDecals?: boolean; - coordinateDebugOpen?: boolean; - coordinateDebugView?: CoordinateDebugView; - }; -} - -export class Environment extends Component { - private localLadder = createFocusLadder(); - private localRuntime = createSurfaceRuntime(); - private localLiftManager = createLiftManager(); - private generatedId: string | undefined; - @consume(PathContextName) declare inheritedSurfacePath: Path | undefined; - @consume(CoordinateSpaceContextName) declare inheritedCoordinateSpace: - | CoordinateSpaceContext - | undefined; - @consume(SurfaceScopeContextName) declare inheritedScopeRelay: - | SurfaceScopeRelay - | undefined; - private localScopeRelay: SurfaceScopeRelay | undefined; - - constructor(owner: Owner, args: EnvironmentSignature['Args']) { - super(owner, args); - const endInitialRuntimeBatch = this.localRuntime.beginBatch(); - queueMicrotask(endInitialRuntimeBatch); - } - - get surface(): LadderSurface { - return 'space'; - } - - get scopeRelay(): SurfaceScopeRelay { - let relay = this.localScopeRelay; - if (!relay || relay.parent !== this.inheritedScopeRelay) { - relay = createSurfaceScopeRelay(this.inheritedScopeRelay); - // eslint-disable-next-line ember/no-side-effects - this.localScopeRelay = relay; - } - return relay; - } - - get spaceIdentity(): Identity | undefined { - return this.args.space ?? this.args.identity; - } - - get localCoordinate(): LocalCoordinate | undefined { - return this.args.coord ?? this.args.at; - } - - @cached - get coordinateSchema(): CoordinateSpace | undefined { - return ( - this.args.schema ?? - this.args.coordinateSpace ?? - (this.spaceIdentity !== undefined - ? defaultCoordinateSpaceSchema(this.surface) - : undefined) - ); - } - - @cached - get id(): string { - if (this.args.id) { - return this.args.id; - } - - if (this.surfacePath !== undefined) { - return surfaceIdFromPath('environment', this.surfacePath); - } - - if (this.spaceIdentity !== undefined) { - return surfaceId('environment', this.spaceIdentity, ...this.keyParts); - } - - // eslint-disable-next-line ember/no-side-effects - this.generatedId ??= nextSurfaceId('environment'); - return this.generatedId; - } - - @cached - get focusKey(): string | undefined { - if (this.args.focusKey) { - return this.args.focusKey; - } - - if (this.surfacePath !== undefined) { - return surfaceFocusKeyFromPath(this.surfacePath); - } - - if (this.spaceIdentity !== undefined) { - return surfaceFocusKey(this.spaceIdentity, ...this.keyParts); - } - - return undefined; - } - - get pathAttribute(): string | undefined { - return this.surfacePath !== undefined - ? surfaceFocusKeyFromPath(this.surfacePath) - : undefined; - } - - @cached - get coordinate(): string | undefined { - if ( - this.args.coord !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - let local = coordinatePartAttribute(this.args.coord); - return local !== undefined - ? `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]:${local}` - : `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]`; - } - - if (this.coordinateSchema !== undefined) { - let local = coordinatePartAttribute(this.localCoordinate); - let spaceId = this.coordinateSpaceId; - return local !== undefined - ? `${spaceId}[${this.coordinateSchema}]:${local}` - : `${spaceId}[${this.coordinateSchema}]`; - } - - if ( - this.localCoordinate !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - let local = coordinatePartAttribute(this.localCoordinate); - return local !== undefined - ? `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]:${local}` - : `${this.inheritedCoordinateSpace.id}[${this.inheritedCoordinateSpace.schema}]`; - } - - return this.pathAttribute ?? this.args.focusKey; - } - - get coordinateSpaceAttribute(): string | undefined { - if (this.coordinateSchema !== undefined) { - return this.coordinateSchema; - } - - if (this.localCoordinate !== undefined) { - return this.inheritedCoordinateSpace?.schema; - } - - return undefined; - } - - get localCoordinateAttribute(): string | undefined { - return coordinatePartAttribute(this.localCoordinate); - } - - get directiveDepthAttribute(): string | undefined { - return this.args.depth === undefined ? undefined : String(this.args.depth); - } - - @cached - get coordinateSpaceId(): string { - if ( - this.coordinateSchema === undefined && - this.localCoordinate !== undefined && - this.inheritedCoordinateSpace !== undefined - ) { - return this.inheritedCoordinateSpace.id; - } - - return this.focusKey ?? this.id; - } - - get providedCoordinateSpace(): CoordinateSpaceContext | undefined { - if (this.coordinateSchema !== undefined) { - return { - surface: this.surface, - id: this.focusKey ?? this.id, - schema: this.coordinateSchema, - }; - } - - return this.inheritedCoordinateSpace; - } - - get coordinateSource(): SurfaceCoordinateSource { - if ( - this.args.surfacePath !== undefined || - this.args.focusKey !== undefined || - this.args.id !== undefined || - this.args.space !== undefined || - this.args.schema !== undefined || - this.args.coord !== undefined || - this.args.coordinateSpace !== undefined || - this.args.at !== undefined - ) { - return 'explicit'; - } - - if (this.spaceIdentity !== undefined) { - return 'identity'; - } - - if (this.inheritedSurfacePath !== undefined) { - return 'context'; - } - - return 'generated'; - } - - get usesGeneratedId(): boolean { - return ( - this.args.id === undefined && - this.spaceIdentity === undefined && - this.surfacePath === undefined - ); - } - - get keyParts(): IdentityPart[] { - if (Array.isArray(this.args.key)) { - return this.args.key; - } - - if (this.args.key !== undefined) { - return [this.args.key]; - } - - if (Array.isArray(this.args.identityPart)) { - return this.args.identityPart; - } - - if (this.args.identityPart !== undefined) { - return [this.args.identityPart]; - } - - return []; - } - - get surfacePath(): Path | undefined { - if (this.args.surfacePath !== undefined) { - return this.args.surfacePath; - } - - if (this.spaceIdentity !== undefined) { - return normalizeSurfacePath(this.spaceIdentity, this.keyParts); - } - - if (this.inheritedSurfacePath !== undefined) { - return [...this.inheritedSurfacePath, ...this.keyParts]; - } - - return undefined; - } - - get ladder(): FocusLadder { - return this.args.ladder ?? this.localLadder; - } - - get runtime(): SurfaceRuntime { - return this.localRuntime; - } - - @cached - get runtimeGridCoordinate(): FociGridCoordinate | undefined { - if (this.args.grid) return this.args.grid; - if (this.args.gridRow === undefined || this.args.gridCol === undefined) { - return undefined; - } - return { - row: this.args.gridRow, - col: this.args.gridCol, - }; - } - - @cached - get runtimePolicy(): FociNodePolicy | undefined { - const policy: FociNodePolicy = { - ...(this.args.runtimePolicy ?? {}), - }; - if (this.args.preset !== undefined) policy.preset = this.args.preset; - if (this.args.aspects !== undefined) policy.aspects = this.args.aspects; - if (this.args.runtimeTraversal !== undefined) { - policy.traversal = this.args.runtimeTraversal; - } - if (this.args.runtimeTraversalModel !== undefined) { - policy.traversalModel = this.args.runtimeTraversalModel; - } - if (this.args.runtimeSelection !== undefined) { - policy.selection = this.args.runtimeSelection; - } - if (this.args.runtimeKeyboard !== undefined) { - policy.keyboard = this.args.runtimeKeyboard; - } - if (this.args.runtimeMovement !== undefined) { - policy.movement = this.args.runtimeMovement; - } - if (this.args.runtimePointer !== undefined) { - policy.pointer = this.args.runtimePointer; - } - if (this.args.runtimeEdit !== undefined) { - policy.edit = this.args.runtimeEdit; - } - if (this.args.accepts !== undefined) policy.accepts = this.args.accepts; - if (this.args.payloadType !== undefined) { - policy.payloadType = this.args.payloadType; - } - - return Object.keys(policy).length > 0 ? policy : undefined; - } - - get liftManager(): LiftManager { - // eslint-disable-next-line ember/no-side-effects - this.localLiftManager.resolver = this.args.liftResolver; - return this.localLiftManager; - } - - get liftTargetComponent(): LiftTargetComponent | undefined { - return this.liftManager.targetComponent; - } - - get liftTargetContext(): LiftTargetContext | undefined { - return this.liftManager.targetContext; - } - - get activeLiftSourceId(): string | undefined { - return this.liftManager.activeSourceId; - } - - get activeLiftTargetId(): string | undefined { - return this.liftManager.activeTargetId; - } - - get activeLiftKind(): string | undefined { - return this.liftManager.kind; - } - - get activeLiftFocusToken(): number | undefined { - return this.liftManager.focusToken; - } - - get demo(): DemoMode { - return this.args.demo ?? false; - } - - get mode(): Mode { - return this.args.mode ?? modeForPosture(this.args.posture) ?? 'use'; - } - - get inspect(): boolean { - return this.args.inspect ?? this.mode === 'inspect'; - } - - get inspectAttribute(): string { - return String(this.inspect); - } - - get changeRoute(): ChangeRoute { - return this.args.changeRoute ?? 'auto'; - } - - get skipKeyboard(): boolean { - return ( - this.args.keyboard === false || - this.args.keyboard === 'manual' || - this.args.keyboard === 'none' - ); - } - - @provide(LadderContextName) - get providedLadder(): FocusLadder { - return this.ladder; - } - - @provide(SurfaceRuntimeContextName) - get providedSurfaceRuntime(): SurfaceRuntime { - return this.runtime; - } - - @provide(ParentIdContextName) - get providedParentId(): string { - return this.id; - } - - @provide(ParentContextName) - get providedParentSurface(): LadderSurface { - return this.surface; - } - - @provide(DemoContextName) - get providedDemo(): DemoMode { - return this.demo; - } - - @provide(ModeContextName) - get providedMode(): Mode { - return this.mode; - } - - @provide(InspectContextName) - get providedInspect(): boolean { - return this.inspect; - } - - @provide(ChangeRouteContextName) - get providedChangeRoute(): ChangeRoute { - return this.changeRoute; - } - - @provide(PathContextName) - get providedSurfacePath(): Path | undefined { - return this.surfacePath; - } - - @provide(CoordinateSpaceContextName) - get providedCoordinateSpaceContext(): CoordinateSpaceContext | undefined { - return this.providedCoordinateSpace; - } - - @provide(LiftContextName) - get providedLiftManager(): LiftManager { - return this.liftManager; - } - - @provide(SurfaceScopeContextName) - get providedScopeRelay(): SurfaceScopeRelay { - return this.scopeRelay; - } - - -} - -export class Layout extends SurfaceComponent { - get surface(): LadderSurface { - return 'layout'; - } -} - -export class Canvas extends SurfaceComponent { - get surface(): LadderSurface { - return 'canvas'; - } -} - -export class Scene extends SurfaceComponent { - get surface(): LadderSurface { - return 'scene'; - } -} - -export class Grid extends SurfaceComponent { - get surface(): LadderSurface { - return 'grid'; - } -} - -export class Row extends SurfaceComponent { - get surface(): LadderSurface { - return 'row'; - } -} - -export class Scroll extends SurfaceComponent { - get surface(): LadderSurface { - return 'scroll'; - } -} - -export class Flow extends SurfaceComponent { - get surface(): LadderSurface { - return 'flow'; - } -} - -export class Frame extends SurfaceComponent { - get surface(): LadderSurface { - return 'frame'; - } -} - -export class Connection extends SurfaceComponent { - get surface(): LadderSurface { - return 'connection'; - } -} - -export class Pane extends SurfaceComponent { - get surface(): LadderSurface { - return 'pane'; - } -} - -export class Plane extends SurfaceComponent { - get surface(): LadderSurface { - return 'plane'; - } -} - -export class Outline extends SurfaceComponent { - get surface(): LadderSurface { - return 'outline'; - } -} - -export interface CellSignature extends SurfaceComponentSignature { - Args: SurfaceComponentSignature['Args'] & { - /** Force a specific chrome surface, skipping DOM detection. */ - surface?: CellSurface; - state?: CellValidationState; - disabled?: boolean; - readonly?: boolean; - bottomTreatment?: 'flat' | 'rounded'; - /** Omits the outer border so adjacent cells can visually chain. */ - chained?: boolean; - }; - Blocks: { default: []; pre: []; post: [] }; - Element: HTMLElement; -} - -// Each FORM token uses the surfaces semantic slot (e.g. `--border`) WITH -// a boxel-ui native-token fallback (e.g. `--boxel-form-control-border-color`). -// This way the chrome works inside boxel-ui hosts (CardDef edit views, realms) -// that only ship the boxel-ui token layer, and surfaces themes can still -// override by setting the semantic slot. -const FORM_VARS = - [ - '--cell-padding:var(--boxel-sp-xs) var(--boxel-sp-sm) var(--boxel-sp-xs) var(--boxel-sp-sm)', - '--cell-border:1px solid var(--border, var(--boxel-form-control-border-color, var(--boxel-300, #d3d3d3)))', - '--cell-radius:var(--boxel-form-control-border-radius, var(--boxel-border-radius, 10px))', - '--cell-outline:1px solid transparent', - '--cell-bg:var(--background, var(--boxel-light, #ffffff))', - '--cell-fg:var(--foreground, var(--boxel-dark, #000000))', - '--cell-height:auto', - '--cell-min-height:var(--boxel-form-control-height, 2.5rem)', - '--cell-focus-shadow:0 0 0 1px var(--ring, var(--boxel-highlight, #00ffba))', - '--cell-focus-border:var(--ring, var(--boxel-highlight, #00ffba))', - '--cell-overflow:grow', - '--cell-placeholder-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--cell-error-color:var(--destructive, var(--boxel-error-200, #ff5050))', - '--cell-helper-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--boxel-input-height:var(--boxel-form-control-height, 2.5rem)', - '--boxel-form-control-border-color:var(--border, var(--boxel-300, #d3d3d3))', - '--boxel-form-control-border-radius:var(--boxel-form-control-border-radius, var(--boxel-border-radius, 10px))', - '--boxel-form-control-box-shadow:none', - ].join(';') + ';'; - -const GRID_VARS = - [ - '--cell-padding:0 var(--boxel-sp-xs)', - '--cell-border:0', - '--cell-radius:0', - '--cell-outline:1.5px solid var(--ring, var(--boxel-highlight, #00ffba))', - '--cell-bg:transparent', - '--cell-fg:inherit', - '--cell-height:100%', - '--cell-min-height:0', - '--cell-focus-shadow:none', - '--cell-focus-border:inherit', - '--cell-overflow:lift', - '--cell-placeholder-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--cell-error-color:var(--destructive, var(--boxel-error-200, #ff5050))', - '--cell-helper-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--boxel-input-height:100%', - '--boxel-form-control-height:100%', - '--boxel-form-control-border-color:transparent', - '--boxel-form-control-border-radius:0', - '--boxel-form-control-box-shadow:none', - ].join(';') + ';'; - -const CANVAS_VARS = - [ - '--cell-padding:var(--boxel-sp-xs)', - '--cell-border:0', - '--cell-radius:var(--boxel-border-radius-xs, 4px)', - '--cell-outline:0', - '--cell-bg:transparent', - '--cell-fg:inherit', - '--cell-height:auto', - '--cell-min-height:0', - '--cell-focus-shadow:0 0 0 1px var(--ring, var(--boxel-highlight, #00ffba))', - '--cell-focus-border:var(--ring, var(--boxel-highlight, #00ffba))', - '--cell-overflow:grow', - '--cell-placeholder-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--cell-error-color:var(--destructive, var(--boxel-error-200, #ff5050))', - '--cell-helper-color:var(--muted-foreground, var(--boxel-450, #919191))', - '--boxel-input-height:auto', - '--boxel-form-control-border-color:transparent', - '--boxel-form-control-border-radius:var(--boxel-border-radius-xs, 4px)', - '--boxel-form-control-box-shadow:none', - ].join(';') + ';'; - -const SCENE_VARS = - [ - '--cell-padding:var(--boxel-sp-sm) var(--boxel-sp)', - '--cell-border:1px solid color-mix(in oklch, var(--primary-foreground) 18%, transparent)', - '--cell-radius:var(--boxel-border-radius-sm)', - '--cell-outline:0', - '--cell-bg:color-mix(in oklch, var(--primary-foreground) 5%, transparent)', - '--cell-fg:inherit', - '--cell-height:auto', - '--cell-min-height:0', - '--cell-focus-shadow:0 0 0 1px color-mix(in oklch, var(--primary-foreground) 45%, transparent)', - '--cell-focus-border:color-mix(in oklch, var(--primary-foreground) 45%, transparent)', - '--cell-overflow:grow', - '--cell-placeholder-color:color-mix(in oklch, var(--primary-foreground) 50%, transparent)', - '--cell-error-color:var(--destructive)', - '--cell-helper-color:color-mix(in oklch, var(--primary-foreground) 55%, transparent)', - '--boxel-input-height:auto', - '--boxel-form-control-border-color:color-mix(in oklch, var(--primary-foreground) 18%, transparent)', - '--boxel-form-control-border-radius:var(--boxel-border-radius)', - '--boxel-form-control-box-shadow:none', - ].join(';') + ';'; - -const VARS_BY_SURFACE: Record = { - form: FORM_VARS, - grid: GRID_VARS, - canvas: CANVAS_VARS, - scene: SCENE_VARS, -}; - -export class Cell extends SurfaceComponent { - private cellGuid = guidFor(this); - - @consume(FormFieldContextName) declare inheritedFormField: - | FormFieldContext - | undefined; - - @tracked private detectedCellSurface: CellSurface = 'form'; - @tracked private detectedState: CellValidationState = 'none'; - - get surface(): LadderSurface { - return 'cell'; - } - - detectCell = modifier((el: HTMLElement) => { - let formFieldState = el - .closest('[data-bx-form-field-state]') - ?.getAttribute('data-bx-form-field-state'); - this.detectedState = isCellValidationState(formFieldState) - ? formFieldState - : 'none'; - - if (this.args.surface) { - this.detectedCellSurface = this.args.surface; - return; - } - - this.detectedCellSurface = el.closest('[data-bx-grid]') - ? 'grid' - : el.closest( - '[data-bx-canvas-node-id], [data-bx-canvas-edge-id], [data-bx-canvas-runtime-root]', - ) - ? 'canvas' - : el.closest('[data-bx-scene-node-id], [data-bx-scene-runtime-root]') - ? 'scene' - : 'form'; - }); - - get cellSurface(): CellSurface { - return this.args.surface ?? this.detectedCellSurface; - } - - get style(): string { - return VARS_BY_SURFACE[this.cellSurface]; - } - - get overflow(): 'grow' | 'lift' { - return this.cellSurface === 'grid' ? 'lift' : 'grow'; - } - - get bottomTreatment(): 'flat' | 'rounded' { - return this.args.bottomTreatment ?? 'rounded'; - } - - get state(): CellValidationState { - return ( - this.args.state ?? this.inheritedFormField?.state ?? this.detectedState - ); - } - - get disabled(): boolean { - return this.args.disabled ?? this.inheritedFormField?.disabled ?? false; - } - - get readonly(): boolean { - return this.args.readonly ?? this.inheritedFormField?.readonly ?? false; - } - - get surfaceRole(): Role { - return this.args.role ?? 'control'; - } - - get surfaceTarget(): Target { - return ( - this.args.target ?? (this.cellSurface === 'grid' ? 'range-item' : 'value') - ); - } - - get defaultFocusOwner(): 'inner' | 'none' { - return this.cellSurface === 'grid' ? 'none' : 'inner'; - } - - override get keyParts(): IdentityPart[] { - if (this.args.key !== undefined || this.args.identityPart !== undefined) { - return super.keyParts; - } - - let key = this.inheritedFormField?.surfaceKey ?? this.cellGuid; - return Array.isArray(key) ? key : [key]; - } - - -} - -function isCellValidationState( - value: string | null | undefined, -): value is CellValidationState { - return ( - value === 'none' || - value === 'valid' || - value === 'invalid' || - value === 'loading' || - value === 'initial' - ); -} - -export class Run extends SurfaceComponent { - get surface(): LadderSurface { - return 'run'; - } -} - -export class Unit extends SurfaceComponent { - get surface(): LadderSurface { - return 'unit'; - } -} diff --git a/boxel-surface/src/components/switch-cell.gts b/boxel-surface/src/components/switch-cell.gts deleted file mode 100644 index 40a1559e..00000000 --- a/boxel-surface/src/components/switch-cell.gts +++ /dev/null @@ -1,198 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { consume } from 'ember-provide-consume-context'; - -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; -import type { FociNodePolicy } from './surface-component.gts'; -import { Cell } from './surface-component.gts'; - -export interface SwitchCellSignature { - Args: { - label: string; - description?: string; - value?: boolean; - disabled?: boolean; - onChange?: (value: boolean) => void; - runtimePolicy?: FociNodePolicy; - }; - Element: HTMLElement; -} - -export default class SwitchCell extends Component { - @consume(FormFieldContextName) declare inheritedFormField: - | FormFieldContext - | undefined; - - get checked(): boolean { - return Boolean(this.args.value); - } - - // HTML has no `readonly` for buttons, so inherited readonly collapses - // into `disabled` — the only "not interactable" state a switch can show. - get isDisabled(): boolean { - if (this.args.disabled !== undefined) return this.args.disabled; - return Boolean( - this.inheritedFormField?.disabled || this.inheritedFormField?.readonly, - ); - } - - @action - toggle(): void { - if (this.isDisabled) return; - this.args.onChange?.(!this.checked); - } - - -} diff --git a/boxel-surface/src/components/text-cell.gts b/boxel-surface/src/components/text-cell.gts deleted file mode 100644 index 3dd1bb70..00000000 --- a/boxel-surface/src/components/text-cell.gts +++ /dev/null @@ -1,197 +0,0 @@ -import { on } from '@ember/modifier'; -import { action } from '@ember/object'; -import Component from '@glimmer/component'; -import { consume } from 'ember-provide-consume-context'; - -import { - FormFieldContextName, - type FormFieldContext, -} from '../form-field-context.ts'; -import type { - CellValidationState, - FociNodePolicy, -} from './surface-component.gts'; -import { Cell } from './surface-component.gts'; - -export interface TextCellSignature { - Args: { - value?: string; - placeholder?: string; - state?: CellValidationState; - disabled?: boolean; - readonly?: boolean; - multiline?: boolean; - type?: 'text' | 'tel' | 'url' | 'search'; - autocomplete?: string; - prefix?: string; - suffix?: string; - onInput?: (value: string) => void; - runtimePolicy?: FociNodePolicy; - }; - Element: HTMLElement; -} - -export default class TextCell extends Component { - @consume(FormFieldContextName) declare inheritedFormField: - | FormFieldContext - | undefined; - - @action - handleInput(event: Event): void { - this.args.onInput?.( - (event.target as HTMLInputElement | HTMLTextAreaElement).value, - ); - } - - get inputType(): 'text' | 'tel' | 'url' | 'search' { - return this.args.type ?? 'text'; - } - - get isReadonly(): boolean { - return this.args.readonly ?? this.inheritedFormField?.readonly ?? false; - } - - get isDisabled(): boolean { - return this.args.disabled ?? this.inheritedFormField?.disabled ?? false; - } - -