diff --git a/deno.lock b/deno.lock index 3534388..f113e19 100644 --- a/deno.lock +++ b/deno.lock @@ -1840,6 +1840,8 @@ "packages/wc": { "dependencies": [ "jsr:@es-toolkit/es-toolkit@^1.39.9", + "jsr:@levischuck/tiny-qr-svg@^0.0.9", + "jsr:@levischuck/tiny-qr@^0.0.9", "npm:@chenglou/pretext@^0.0.7", "npm:@lit/context@^1.1.6", "npm:lazy-brush@^2.0.2", diff --git a/examples/showcase.md b/examples/showcase.md index 295f02b..d8cfdf4 100644 --- a/examples/showcase.md +++ b/examples/showcase.md @@ -5,6 +5,8 @@ location: Portland, Oregon speaker: Graham Barber pronouns: he/him handle: "@graham.systems" +avatar: https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=400&q=80 +qr: true links: github: gambarber bluesky: graham.bsky.social diff --git a/packages/core/renderer.ts b/packages/core/renderer.ts index 90ecf66..bf314e9 100644 --- a/packages/core/renderer.ts +++ b/packages/core/renderer.ts @@ -58,6 +58,19 @@ interface DeckMeta { * also include it under `links`. */ handle?: string; + /** + * Optional URL / path to a portrait image. Rendered as a circular + * headshot — large above the speaker name on the outro slide, + * small to the left of the attribution on the title slide. + */ + avatar?: string; + /** + * Opt-in QR code on the outro slide. `true` generates a code + * pointing at the document's own URL at view time (so the QR + * always tracks where the deck is actually hosted). A string + * value pins the QR to a fixed canonical URL instead. + */ + qr?: boolean | string; links?: Record; } @@ -200,41 +213,46 @@ function renderColophon(meta: DeckMeta): string { ); } - if (!hasAttribution && linkItems.length === 0) return ""; + const avatarHtml = meta.avatar + ? `${
+      escapeHtml(meta.speaker ?? ` + : ""; + + if (!hasAttribution && linkItems.length === 0 && !avatarHtml) return ""; - let attributionHtml = ""; - if (hasAttribution) { - let speakerHtml = ""; - if (meta.speaker) { - // The name carries its own line, weight 700 + Text color, so - // it reads as the primary identity moment uncrowded. - // Pronouns + handle ride a quieter "byline" line beneath it, - // sharing typographic weight so neither dominates: pronouns - // get Recursive's slnt/CASL axes (italic aside, the gesture - // DESIGN.md uses for blockquotes), handle gets MONO (technical - // identifier). The byline only renders if at least one of - // them is present. - speakerHtml = - `

${escapeHtml(meta.speaker)}

`; - const bylinePieces: string[] = []; - if (meta.pronouns) { - bylinePieces.push( - `${escapeHtml(meta.pronouns)}`, - ); - } - if (meta.handle) { - bylinePieces.push( - `${escapeHtml(meta.handle)}`, - ); - } - if (bylinePieces.length > 0) { - speakerHtml += `

${ - bylinePieces.join( - ` `, - ) - }

`; - } + // Author block: avatar + the text stack (speaker, byline, context) + // emitted as siblings inside a single .colophon-author container. + // Per-layout CSS arranges them: title pairs the avatar with the + // speaker name in a 2-column grid (avatar | name on row 1, byline + // and context spanning both columns on rows 2 and 3) so the + // avatar's vertical center sits at the name's vertical center — + // not at the byline (the smallest line) the way a flex `center` + // alignment against the whole 3-line block would have produced. + // Outro keeps the avatar above the text stack as a portrait. + let authorHtml = ""; + if (hasAttribution || avatarHtml) { + const speakerHtml = meta.speaker + ? `

${escapeHtml(meta.speaker)}

` + : ""; + const bylinePieces: string[] = []; + if (meta.pronouns) { + bylinePieces.push( + `${escapeHtml(meta.pronouns)}`, + ); + } + if (meta.handle) { + bylinePieces.push( + `${escapeHtml(meta.handle)}`, + ); } + const bylineHtml = bylinePieces.length === 0 + ? "" + : `

${ + bylinePieces.join( + ` `, + ) + }

`; const contextHtml = contextParts.length === 0 ? "" : `

${ @@ -242,15 +260,41 @@ function renderColophon(meta: DeckMeta): string { ` `, ) }

`; - attributionHtml = - `
${speakerHtml}${contextHtml}
`; + authorHtml = `
` + + `${avatarHtml}${speakerHtml}${bylineHtml}${contextHtml}` + + `
`; } const linksHtml = linkItems.length === 0 ? "" : ``; - return `
${attributionHtml}${linksHtml}
`; + return `
${authorHtml}${linksHtml}
`; +} + +/** + * QR block rendered on the outro slide when `meta.qr` is truthy. + * The component generates the SVG on the client so it can pick up + * the document's live URL — handing the audience a scan that + * always points back to wherever they're currently viewing the + * deck. A string-valued `meta.qr` pins the encoded value instead. + * + * The `` element accepts a slotted label so this + * stays content-driven; we hand it the conventional "scan for + * these slides" so the call-to-action is legible without the + * audience having to interpret a bare QR. + */ +function renderQrBlock(meta: DeckMeta): string { + if (!meta.qr) return ""; + const value = typeof meta.qr === "string" + ? ` value="${escapeHtml(meta.qr)}"` + : ""; + // Wrap the slot text in a so the QR component's + // ::slotted() rule can reach it — bare text nodes can't be styled + // through the shadow boundary. + return `
` + + `scan for these slides` + + `
`; } export async function renderPresentationHtml( @@ -371,15 +415,18 @@ export async function renderPresentationHtml( // descendant of the shadow-DOM section, so :has() can't see // it. A host attribute is the bridge across the boundary. let hasColophon = ""; + let hasQr = ""; if (layout === "title" || layout === "outro") { const marquee = meta ? renderMarquee(meta) : ""; const colophon = meta ? renderColophon(meta) : ""; - if (marquee || colophon) { - body = `${marquee}
${body}
${colophon}`; + const qr = meta && layout === "outro" ? renderQrBlock(meta) : ""; + if (marquee || colophon || qr) { + body = `${marquee}
${body}
${qr}${colophon}`; hasColophon = " data-has-colophon"; + if (qr) hasQr = " data-has-qr"; } } - return `${body}`; + return `${body}`; }, columns: async ([, , ...children], { render }) => { return `
${ diff --git a/packages/core/templates/partials/slide-styles.eta b/packages/core/templates/partials/slide-styles.eta index 8dc9782..688ccd6 100644 --- a/packages/core/templates/partials/slide-styles.eta +++ b/packages/core/templates/partials/slide-styles.eta @@ -618,10 +618,10 @@ min-height: 0; } - /* ---- Grid placement of the three blocks ---- - * The shadow-DOM section (slide.ts) sets up a two-column / two-row - * grid template-areas for title/outro slides. Light-DOM children - * (.marquee, .hero-body, .colophon) are slotted in and need to be + /* ---- Grid placement of the title/outro blocks ---- + * The shadow-DOM section (slide.ts) sets up a grid-template-areas + * for title/outro slides. Light-DOM children (.marquee, + * .hero-body, .colophon, .qr-block) are slotted in and need to be * assigned to their grid areas from the document stylesheet — the * grid container is in the shadow DOM but slotted children * participate in its layout via display: contents on the slot. */ @@ -634,6 +634,9 @@ morkdeck-slide[data-has-colophon] > .colophon { grid-area: colophon; } + morkdeck-slide[data-has-qr] > .qr-block { + grid-area: qr; + } /* Marquee. Theatre-marquee tone: mono caps, label tracking, Muted color, hairline rules above and below. The track is rendered @@ -683,19 +686,99 @@ } } - /* The .colophon-attribution wrapper holds speaker name, byline, - and event context — a single stacked block of text. The - outer .colophon then arranges this text block against the - socials icons depending on layout (row for title, column for - outro). All metrics in cqi/cqmin so the rhythm scales with - the slide. */ - morkdeck-slide .colophon-attribution { + /* Avatar — editorial portrait. Circular crop, sized as a + fraction of the slide so it scales with the venue. */ + morkdeck-slide .colophon-avatar { + display: block; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 50%; + border: 1px solid var(--morkdeck-color-highlight-med); + background: var(--morkdeck-color-surface); + } + + /* ---- Author block: avatar + text stack ---- + Title layout uses a 2-column grid so the avatar pairs with the + speaker name on row 1 (vertically centered against the name + alone, not against the whole multi-line block — that's why the + earlier flex `align-items: center` against .colophon felt off: + it centered against the byline line, not the name). Byline and + context span both columns on rows 2 and 3 so they flow at the + same left edge as the avatar. + + The column gap is in em so it tracks the speaker font-size — + a tight 0.5em hugs the avatar to the name without crowding. */ + morkdeck-slide[data-layout="title"] .colophon-author { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.5em; + row-gap: 0.3em; + min-width: 0; + flex: 1 1 auto; + } + + morkdeck-slide[data-layout="title"] .colophon-avatar { + grid-column: 1; + grid-row: 1; + width: 4.4cqi; + align-self: center; + } + + morkdeck-slide[data-layout="title"][data-density="balanced"] .colophon-avatar { + width: 4cqi; + } + + morkdeck-slide[data-layout="title"][data-density="dense"] .colophon-avatar { + width: 3.6cqi; + } + + morkdeck-slide[data-layout="title"] .colophon-speaker { + grid-column: 2; + grid-row: 1; + align-self: center; + } + + /* Byline and context hang-indent under the name (column 2), not + all the way to the colophon-author's left edge. Without this + the name reads as indented past the supporting text, breaking + the left margin. */ + morkdeck-slide[data-layout="title"] .colophon-byline { + grid-column: 2; + grid-row: 2; + } + + morkdeck-slide[data-layout="title"] .colophon-context { + grid-column: 2; + grid-row: 3; + } + + /* Density tiers add margin-bottom to every

in the slide for + readable vertical lists; the colophon text already has its + own row-gap rhythm and doesn't want that extra space (which + also shifts the optical center of the name away from the + avatar's grid-row centerline). Two-class selector beats the + density rule's specificity. */ + morkdeck-slide .colophon .colophon-speaker, + morkdeck-slide .colophon .colophon-byline, + morkdeck-slide .colophon .colophon-context { + margin: 0; + } + + /* Outro: vertical stack. The big circular portrait sits above + the text block; speaker / byline / context flow as a column. */ + morkdeck-slide[data-layout="outro"] .colophon-author { display: flex; flex-direction: column; + align-items: flex-start; gap: 0.4em; min-width: 0; } + morkdeck-slide[data-layout="outro"] .colophon-avatar { + width: 12cqmin; + margin-bottom: 1cqmin; + } + /* Title colophon: horizontal row spanning the slide. Attribution block on the left, socials on the right, hairline above to separate from the hero content. flex-end alignment puts the @@ -832,4 +915,22 @@ flex: none; display: block; } + + /* QR block (outro slides only). Sits in the bottom-left of the + outro grid, anchored to the bottom so its baseline aligns + visually with the wordmark above it. Sized to be reliably + scannable at projector distances while staying restrained in + the composition. */ + morkdeck-slide .qr-block { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + min-width: 0; + } + + morkdeck-slide .qr-block morkdeck-qr { + width: 18cqmin; + max-width: 100%; + } diff --git a/packages/wc/components/qr.ts b/packages/wc/components/qr.ts new file mode 100644 index 0000000..b23e426 --- /dev/null +++ b/packages/wc/components/qr.ts @@ -0,0 +1,130 @@ +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { qrCode } from "@levischuck/tiny-qr"; +import { toSvgString } from "@levischuck/tiny-qr-svg"; +import { color, dimension, fontFamily } from "@morkdeck/theme/css"; + +/** + * QR code rendered to inline SVG at runtime. + * + * Encodes either an explicit `value` attribute or, if none is given, + * the document's own URL (with the slide hash stripped so the QR + * stays stable across slides). Generation is client-side because we + * don't know at build time where the deck will be deployed — + * `` always points to wherever it's actually being + * viewed. + * + * Uses @levischuck/tiny-qr — a dependency-free TypeScript encoder + * that returns a boolean matrix — paired with @levischuck/tiny-qr-svg + * for the SVG output. Both ship pure-JS to the browser bundle. + * + * The QR ink color matches the slide's reading text token so the + * code feels like part of the editorial palette, not a foreign + * scanner artifact. Background is transparent so it sits on the + * slide's base surface without a card. + */ +@customElement("morkdeck-qr") +export class QR extends LitElement { + /** + * The value to encode. When empty, the component defaults to the + * current document URL (with `#fragment` stripped). Setting this + * to a fixed string takes precedence — useful for talks that + * want their QR pointing at a permanent canonical URL rather + * than wherever the deck happens to be hosted right now. + */ + @property({ type: String }) + accessor value = ""; + + @state() + accessor svgMarkup = ""; + + static override styles = css` + :host { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 1cqmin; + } + + .qr { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + } + + .qr svg { + display: block; + width: 100%; + height: 100%; + } + + /* The slot itself doesn't render content — its slotted children + do, and they live in the light DOM. ::slotted lets the shadow + stylesheet reach across the boundary to whatever element the + renderer passed (a ). Without it, the bare text node + would render with whatever the light-DOM cascade gives it, + which was inheriting a loose body line-height. + Left-aligned to join the editorial flow of the rest of the + deck — no other typography on these slides is center-justified, + so a centered label was the lone exception in the grid. */ + ::slotted(*) { + display: block; + font-family: ${fontFamily("mono")}; + font-variation-settings: "MONO" 1; + font-size: 1.3cqi; + letter-spacing: 0.1em; + line-height: 1.2; + text-transform: uppercase; + color: ${color("muted")}; + text-align: left; + max-width: 100%; + } + `; + + override connectedCallback() { + super.connectedCallback(); + this.#generate(); + } + + protected override willUpdate(changed: PropertyValues) { + if (changed.has("value") && this.isConnected) { + this.#generate(); + } + } + + /** + * Generate the QR SVG. tiny-qr is synchronous — no Promise needed. + * tiny-qr-svg's `toSvgString` returns an `SvgResult` + * object (not the bare string the README implies); the SVG + * markup lives on the `.svg` property. + */ + #generate() { + const url = this.value.length > 0 + ? this.value + : document.URL.split("#")[0]; + try { + const qr = qrCode({ data: url }); + const result = toSvgString(qr, { + // Quiet zone is provided by the slide's surrounding white + // space; the SVG itself stays tight to the modules. + margin: 0, + moduleSize: 4, + background: "transparent", + color: "#e0def4", + output: "svg", + }); + this.svgMarkup = result.svg; + } catch (err) { + console.error("morkdeck-qr: failed to generate", err); + this.svgMarkup = ""; + } + } + + override render() { + return html` +

${this.svgMarkup ? unsafeHTML(this.svgMarkup) : nothing}
+ + `; + } +} diff --git a/packages/wc/components/slide.ts b/packages/wc/components/slide.ts index 44aecfd..b2f567a 100644 --- a/packages/wc/components/slide.ts +++ b/packages/wc/components/slide.ts @@ -110,10 +110,11 @@ export class Slide extends MorkdeckElement { padding: ${dimension("space.md")}; display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr) auto; grid-template-areas: "marquee marquee" - "hero colophon"; + "hero colophon" + "qr colophon"; column-gap: ${dimension("space.xl")}; row-gap: ${dimension("space.lg")}; justify-content: stretch; diff --git a/packages/wc/deno.json b/packages/wc/deno.json index 638f6ff..74e1a96 100644 --- a/packages/wc/deno.json +++ b/packages/wc/deno.json @@ -7,6 +7,8 @@ "lit": "npm:lit@^3.3.1", "@lit/context": "npm:@lit/context@^1.1.6", "@chenglou/pretext": "npm:@chenglou/pretext@^0.0.7", - "lazy-brush": "npm:lazy-brush@^2.0.2" + "lazy-brush": "npm:lazy-brush@^2.0.2", + "@levischuck/tiny-qr": "jsr:@levischuck/tiny-qr@^0.0.9", + "@levischuck/tiny-qr-svg": "jsr:@levischuck/tiny-qr-svg@^0.0.9" } } diff --git a/packages/wc/mod.ts b/packages/wc/mod.ts index 426f372..131ca80 100644 --- a/packages/wc/mod.ts +++ b/packages/wc/mod.ts @@ -1,4 +1,5 @@ export * from "./components/presentation/wc.ts"; +export * from "./components/qr.ts"; export * from "./components/slide.ts"; export * from "./components/toolbar.ts"; export * from "./element.ts";