diff --git a/.impeccable/critique/2026-07-11T12-28-14Z__sow-nodebb-theme.md b/.impeccable/critique/2026-07-11T12-28-14Z__sow-nodebb-theme.md new file mode 100644 index 0000000..4eef59f --- /dev/null +++ b/.impeccable/critique/2026-07-11T12-28-14Z__sow-nodebb-theme.md @@ -0,0 +1,84 @@ +--- +target: SoW NodeBB theme visual pass +total_score: 27 +p0_count: 1 +p1_count: 3 +timestamp: 2026-07-11T12-28-14Z +slug: sow-nodebb-theme +--- +# Critique: nodebb-theme-westgate (visual pass) + +Method: dual-agent (A: design-review sub-agent · B: detector-evidence sub-agent), live forum at http://localhost:4567 (NodeBB dev stack, theme rebuilt from working tree @ branch `impeccable`, commit 950f3ba). + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3 | Unread counts, watching chips, draft badge all present; icon-only topbar gives weak "where am I" feedback | +| 2 | Match System / Real World | 3 | Ledger metaphor fits; "World", "Crosspost Topic", "Manage Open Social Web Handles" are opaque jargon | +| 3 | User Control and Freedom | 3 | Discard-with-confirm, Escape closes drawer, breadcrumbs, skip-link | +| 4 | Consistency and Standards | 2 | Two primary-button styles; post tables unthemed while notification/flags tables are themed; pink default avatars | +| 5 | Error Prevention | 3 | Password rules shown upfront on register; discard confirmation | +| 6 | Recognition Rather Than Recall | 2 | 9+ icon-only topbar destinations, tooltip-only labels | +| 7 | Flexibility and Efficiency | 3 | Good search filters and sorts; no keyboard-shortcut affordance anywhere | +| 8 | Aesthetic and Minimalist Design | 3 | Strong restrained system; noise from zero-count stat boxes and 15 zero-count notification filters | +| 9 | Error Recovery | 3 | Themed 404 with home link | +| 10 | Help and Documentation | 2 | Wiki landing leaks admin setup copy to guests; no member onboarding | +| **Total** | | **27/40** | **Acceptable — solid base, specific fixes needed** | + +## Anti-Patterns Verdict + +**Not AI slop, not a default skin.** The velvet gradients, stepped gold borders, Cinzel/Jost pairing, and custom footer are genuinely art-directed. No gradient text, no glassmorphism, no hero-metric template, no eyebrow scaffolding. + +Two ban-level violations and a set of "default NodeBB" tells: + +- **Side-stripe ban violated (and the theme's own Gilt Edge Rule):** topic rows carry a 2px gold `border-left` accent — `scss/westgate/_topics.scss:64` (with state variants at 76–182) and `_widgets.scss:108–148`. Detector confirmed (`side-tab` ×3 on /topic/2). The rule says gold lives in 1px edges. +- **Display font on data:** profile email in Cinzel wraps mid-token ("ADMIN@WESTGATE.LOCA / L"); timestamps as 11.5px uppercase tracked text ("5 MINUTES AGO") — DESIGN.md's Engraving Rule says timestamps keep letter-spacing 0. +- **Default-skin tells:** bright magenta auto-generated avatars, Harmony's grey-triangle profile cover, the near-white "Powered by NodeBB / TipTap" footer pills — the loudest pixels on every page are not the theme's. + +**Deterministic scan:** CLI detector cannot read `.tpl` files, so the shipped templates got zero static coverage; 1786 CLI findings all come from `custom_pages/` mockup artifacts (mostly duplicates/dead files — treat as noise, or delete the stale mockups). The real evidence came from injecting the detector into the live pages: recurring cross-page hits were footer meta text at 4.4:1 contrast (6 of 7 pages), gold glow on footer pills and category cards (`dark-glow`), 1px-border + 16–18px-shadow on the brand mark and avatars, tight line-height 1.25 on card teasers, and ~107–155 characters per line in post/search/wiki prose (aim < 80). Detector's extreme claims (1.1:1 text on gold) are sampling artifacts — visually checked, false positives. + +**Overlays:** the detector ran in a headless browser (Playwright MCP could not launch Chrome on NixOS), so there is no user-visible overlay tab; console findings above are the signal. + +## Overall Impression + +The Velvet Ledger system is real and consistently executed — screenshots are unmistakably this forum, and body text (9.2:1) proves atmosphere is not taxing reading. The biggest opportunity is finishing the last 5%: the places where inherited NodeBB/Bootstrap defaults poke through the velvet (invisible post tables, pink avatars, near-white footer pills, dim focus ring). Each one individually is small; together they are the difference between "themed forum" and "in-world artifact." + +## What's Working + +1. **A real material system, applied everywhere.** The 100° velvet gradient + 1px gold borders at stepped alphas + three panel tones appear identically on cards, composer, search, 404, empty states. Hierarchy comes from tone and border strength, not decoration — the Candlelight Rule holds. +2. **Accessibility bones are genuinely built in.** Skip-link first in tab order, consistent outline token on all tabbed controls, aria states on the burger, Escape closes the drawer, wiki tables get focusable scroll wrappers with labels. Only the focus token's alpha is wrong (see P1). +3. **Thematic parity ~95%.** Login, register, 404, search, composer, notification empty states all stay in-world; zero horizontal scroll on mobile across tested pages. + +## Priority Issues + +1. **[P0] Markdown tables in posts are invisible.** Computed 1.02:1 — near-black text on near-black panel (`rgb(9,8,11)` on `rgb(15,13,18)`). The merchant-price table that is the whole point of the seeded feedback thread cannot be read; any player table (loot splits, event schedules) will hit this. Root cause: `--bs-table-*` overrides exist only for notification tables (`_controls.scss:211`) and flags (`_surfaces.scss:361`); post-content tables fall through to Bootstrap's dark default. Fix: hoist the already-duplicated table-variable block into one shared rule that also covers `[component="post/content"] table`. Both assessments found this independently. → `/impeccable polish` +2. **[P1] Focus ring fails non-text contrast.** `--wg-focus: rgba(194,163,90,0.28)` (`_tokens.scss:98`) ≈ 1.7:1 over crypt black (WCAG wants ≥3:1 for focus indicators). Confirmed visually: barely perceptible on the topbar icons. PRODUCT.md demands visible focus; DESIGN.md itself specifies the failing value, so the spec needs the fix too. Fix: solid `#c2a35a` (≈6:1) for outlines; keep the soft token for input glows. → `/impeccable polish` +3. **[P1] Mobile drawer shows duplicate navigation.** Categories/Recent/Unread/Tags appear twice: `templates/partials/header/topbar.tpl:99–111` loops `{{{ each navigation }}}` then unconditionally appends four hardcoded links. Reads as a glitch on every mobile visit. Fix: delete or guard the hardcoded block. → `/impeccable polish` +4. **[P1] The wiki surface is the weakest page, and it's the front door.** (a) Guests see "Set the Wiki Homepage… You do not have permission" — admin plumbing shown to the visitor most likely arriving from out-of-game links. (b) Console error on every load: `error loading forum/wiki — Cannot find module './wiki'` — the wiki's client-side module is missing from the build (plugin/build issue, worth confirming against production). (c) Wiki card prose runs ~155 chars/line. → `/impeccable harden` (empty/guest states) + a code fix in the wiki plugin or build +5. **[P2] Inherited defaults break the palette on every page.** Bright magenta generated avatars, grey-triangle default profile cover, near-white "Powered by NodeBB / TipTap" pills (`templates/partials/footer/westgate-global.tpl:47–50`), footer meta at 4.4:1 (`#7f756f` on `#09080b` — fails AA by a hair). Fix: constrain generated avatar background colors to a plum/gold/dried-blood set, ship a themed default cover, restyle the powered-by pills to ghost-button treatment, bump footer meta one step toward ash. → `/impeccable polish` + +## Persona Red Flags + +**Alex (power user):** 9 icon-only topbar destinations with tooltip-only labels (flame=Popular, globe=World, inbox=Unread) demand memorization; no keyboard-shortcut affordance anywhere; /notifications makes him scan 15 zero-count filter rows. Positive: search dropdown autofocuses its input. + +**Sam (keyboard/screen-reader):** the ~1.7:1 focus ring makes complete keyboard support practically invisible; the 1.02:1 post tables fail WCAG 1.4.3 catastrophically; 11.5px uppercase tracked metadata is the hardest-to-read treatment applied to the most-repeated text. Positives: skip-link, aria states, Escape handling, focusable table scroll wrappers. + +**Returning SoW roleplayer on mobile:** duplicate drawer nav reads as a glitch every visit; the price table they came to check is invisible; "WATCHING" chips stamped on every topic row tax the list they skim most. Positives: no horizontal scroll at 390px; thumb-friendly bottom pager. + +## Minor Observations + +- Core i18n grammar leaks: "1 pages", "2 result(s)", "1 posts". +- 404 keeps Harmony's playful otter — mildly off the "nothing playful" anti-reference; an in-world line was free. Same for the notification empty state ("wind" icon). +- `public/client.js:140` adds mobile scroll wrappers only to `.westgate-wiki` tables; post-content tables get none. +- Card teaser line-height 1.25 (detector `tight-leading` ×3 on home/recent) — bump to ≥1.3. +- Two primary-button vocabularies: bright gold gradient (topbar Register, composer Submit) vs the spec'd dark gold plate (Login, "Mark all read"). Gilt Edge Rule says the dark plate is the only gold fill — pick one or amend DESIGN.md. +- Post prose ~107 chars/line at desktop width; cap the content column nearer 80ch. +- `custom_pages/` carries stale duplicate mockups (`Westgate v1…`, `uploads/Westgate.dc.html`) that dominate any static scan — consider pruning. +- Sitewide brand text reads "NODEBB" — dev config value, but verify production siteTitle. + +## Questions to Consider + +1. If gold "marks what can be touched," why is the widest gold on the site a decorative 2px stripe on topic rows — while the most touchable thing, the focus ring, is the dimmest gold in the system? +2. The ledger is the court's own book — so why does a guest opening /wiki read the clerk's setup notes, and why are the only near-white surfaces in the theme third-party credits? +3. Should user data (emails, timestamps, usernames) ever wear the Cinzel engraving, or only the court's own inscriptions — and what enforces that when the next plugin ships a table? diff --git a/DESIGN.md b/DESIGN.md index e855d27..d723d0b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -143,7 +143,7 @@ Etched and restrained: controls read as engraved metal — thin gilt borders, qu - **Shape:** softly rounded (Bootstrap default radius; surfaces use 8px). - **Primary:** dark gold plate. Gradient #5a4a1a → #473914, border Old Gilt (#a8893f), text #f0e6d8, with a 1px inner top highlight. Hover warms the gradient to #6a5720 → #53431a and brightens the border to Tarnished Gold. - **Secondary / Ghost:** near-transparent dark fill (rgba(8,7,10,0.12)) with a gilt border at 28% alpha and text #d8cec0. Hover adds a faint gold wash (rgba(194,163,90,0.075)) and strengthens the border. -- **Focus:** 2px outline in the gold focus token (rgba(194,163,90,0.28)). +- **Focus:** 2px outline in solid Tarnished Gold (#c2a35a, the `--wg-focus-ring` token) — WCAG wants ≥3:1 for focus indicators, so the outline never uses the soft alpha token. The soft token (rgba(194,163,90,0.28)) is only for input glows. ### Cards / Containers (the velvet surface) - **Corner Style:** 8px radius. diff --git a/custom_pages/Westgate v1 (approx palette).dc.html b/custom_pages/Westgate v1 (approx palette).dc.html deleted file mode 100644 index 3c6733e..0000000 --- a/custom_pages/Westgate v1 (approx palette).dc.html +++ /dev/null @@ -1,563 +0,0 @@ - - - - - - - - - - - - - - - - -
- - - - - - -
- - -
-
- -
-
-
-
Neverwinter Nights · Persistent World
-

Welcome to Westgate

-

An ancient port city rises out of the mists, at the edge of the Sea of Fallen Stars. Mind the shadows.

-
- How to Join -
Latest News
-
-
-
- - -
-
-
-
-
-
Server Online
-
Alpha · Module 0.3
-
-
-
-
-
14 souls online
-
Peaked 32 today
-
-
-
-
Direct Connect
-
play.westgate.pw
-
- Connect Guide -
-
- - -
-
-
-
-
-
-

Long ago, merchants from distant lands filled its harbors, and gold flowed as freely as wine — but time, as it does, has a way of curdling mortal splendor.

-

Today its bones are old stone and salt-worn docks, and bargains are struck beneath every lantern's glow. Here, hoarded secrets, owed favour, and unspoken truths are the true measure of power — although coin certainly helps.

-

Westgate is a city ever-balanced on a blade's edge, built upon decaying decadence. The masked and nameless guilds clot the veins of the city with their greed and cruelty. Noble houses cling to fading legacies as their bloodlines tangle with curses, ambition, and desperation. Old cults and buried powers hold the Undergate by its throat. Beneath it all, antediluvian things begin to stir — patient, watchful, and hungry.

-
- - To walk Westgate's streets is to wager your soul in a game of chance. -
-

Every alley offers opportunity, yet each carries a price. While some come seeking fortune or power, others arrive hoping to disappear — and indeed they do, for in these boulevards you are lucky to merely survive. In Westgate, few leave unchanged… and fewer still leave at all.

-

Welcome to Westgate. Mind the shadows.

-
- - -
-
-
Your First Steps
-

Enter the City

-
- -
- - -
-
-
-
From the Watch
-

Latest News

-
-
All Dispatches →
-
-
-
-
news image
-
-
24 Jun 2026 · Announcement
-
The Gates Open — Alpha Has Begun
-

Westgate opens its harbour to its first wave of adventurers. Here is everything you need to know.

-
-
-
-
news image
-
-
21 Jun 2026 · Patch Note
-
Patch 0.3 — The Undergate
-

The tunnels beneath the city are now open. New areas, factions, and dangers await below.

-
-
-
-
news image
-
-
18 Jun 2026 · Community
-
The Community Charter
-

Our revised rules and the principles that keep Westgate's streets worth walking.

-
-
-
-
-
-
- - - -
-
-
Dispatches from the Watch
-

News

-

Official server announcements, patch notes, and word from those who keep the gates.

-
- - -
-
- feature image -
-
-
- Announcement - 24 June 2026 -
-

The Gates of Westgate Open — Alpha Has Begun

-

After long months in the shipyards, the harbour is open. Our alpha welcomes its first denizens to walk the salt-worn docks, strike bargains beneath the lanterns, and write the first chapters of Westgate's living history. Read on for what to expect, how to join, and where to begin.

-
- Read Dispatch - by The Watch · 4 min read -
-
-
- - -
- -
21
Jun 2026
-
-
Patch Note
-
Patch 0.3 — The Undergate Expansion
-

New sub-levels, the Quivering Thumb questline, balance passes to thievery, and a reworked lantern lighting system.

-
-
-
- -
18
Jun 2026
-
-
Community
-
The Community Charter & Rules Revision
-

An update to our code of conduct and roleplay standards. Please review before your next session.

-
-
-
- -
12
Jun 2026
-
-
Announcement
-
New Player Onboarding: The Watch Welcomes You
-

A streamlined path from download to your first night in the city — mentors now available on arrival.

-
-
-
- -
06
Jun 2026
-
-
Maintenance
-
Scheduled Module Reset — June 28
-

The server will be briefly unavailable for a planned reset. Expect roughly thirty minutes of downtime.

-
-
-
-
-
-
- - - -
-
-
From the Workshop
-

Developer Blog

-

Behind-the-scenes development updates — the craft, the decisions, and the long work of building a living city.

-
- -
-
- -
-
-
-
screenshot / wip render
-
-
- Worldbuilding - 22 June 2026 · 7 min read -
-

Building the Undergate: Tunnels Beneath the City

-

Westgate has always been a city of two halves — the lantern-lit streets above, and the flooded warrens below. This month we walk you through how the Undergate came together: the layout philosophy, the lighting passes that took far longer than we'd like to admit, and the factions that now call the dark home.

-
-
- archvillainette - · - Read Entry → -
-
-
-
- -
-
-
-
- Systems - 9 June 2026 · 6 min read -
-

Faction Systems & the Masked Guilds

-

Reputation, favour, and betrayal — how the guild standing system tracks the webs of loyalty players weave, and why we chose to let almost everything be undone with the right bribe.

-
-
- The Westgate Team - · - Read Entry → -
-
-
- -
-
-
-
- Art & Atmosphere - 27 May 2026 · 5 min read -
-

Lighting Westgate: A Study in Lanternlight

-

Mood is everything in a city built on shadow. A look at our approach to colour temperature, fog, and the hundreds of placed light sources that make the docks feel cold and the taverns feel like refuge.

-
-
- archvillainette - · - Read Entry → -
-
-
- -
-
-
-
- Design - 14 May 2026 · 8 min read -
-

Why We Chose Persistent Roleplay

-

The first entry. Who we are, what Shadows Over Westgate is trying to be, and the kind of stories we hope you'll tell in its streets.

-
-
- The Westgate Team - · - Read Entry → -
-
-
-
-
-
- - - -
-
-
The Archive
-

Gallery

-

Scenes from the streets of Westgate, captured by its denizens. Drag your own screenshots onto any frame.

-
- -
-
- -
The Undergate at Low Tide
@duskwarden
-
-
- -
Rain over the Harbour
@archvillainette
-
-
- -
Noble Row, House Malforten
@velvethand
-
-
- -
Masks of the Quivering Thumb
@silktongue
-
-
- -
The Tankard's Rest
@ghostofeltabbar
-
-
- -
Cobbles after Curfew
@ashen
-
-
- -
The Sea of Fallen Stars at Dusk
@cartographer
-
-
- -
A Bargain Beneath the Lantern
@nightjar
-
-
- -
Something in the Undergate
@nameless
-
-
-
-
- - - -
- - -
-
-
-
Persistent World · Made by Hand
-

Come build a world that's a pleasure to work on.

-

Westgate is made by a handful of people who love this stuff — artists, writers, builders, scripters. We've kept the tools pleasant so your time goes where it matters: into the world itself. If you make things, we'd love to meet you.

- -
-
-
- - westgate — the undergate -
-
- screenshot · a corner you'll help build -
-
-
-
- - -
-
-
Who We Need
-

Every kind of maker has a place here

-

Code, art, words, world — whatever you make, there's a part of Westgate waiting for it.

-
-
-
-
toolset · scripts
-
-

Tools that stay out of your way

-

For builders and scripters: a clean toolset and modern scripting on a fast core — none of NWScript's old quirks to fight. Everything's saved and versioned, so you can experiment without fear of breaking things.

-
-
-
-
models · textures · music
-
-

Your craft, your stamp

-

For artists and asset-makers: models, textures, areas, sound. We're not after a house style to copy — we want the thing only you'd make, and we'll help get it into the game.

-
-
-
-
lore · quests · factions
-
-

Ideas that reach players

-

For writers and designers: a questline, a faction, a forgotten corner of the city. What you dream up this month is something players are living in the next.

-
-
-
-
- - -
-
-
-
What It's Actually Like
-

Low ego, high trust — and your work goes live.

-

No gatekeeping, no hazing, no “figure it out yourself.” We give honest, kind feedback, lend a hand when it helps, and keep things moving. The thing you make this week is the thing players see this week.

-
-
-
Honest, kind feedback on your work
-
A hand whenever you want one
-
What you make can be in-game within the week
-
Work at whatever pace fits your life
-
-
-
- - -
-
-
-
-
-
-

Sound like your kind of place?

-

No test gauntlet, no résumé. Just come say hi and tell us what you'd love to build.

- - Join the Team on Discord → -
Artists · writers · builders · scripters — all welcome
-
- -
-
- - - - -
-
- - - diff --git a/custom_pages/uploads/Westgate.dc.html b/custom_pages/uploads/Westgate.dc.html deleted file mode 100644 index 3c6733e..0000000 --- a/custom_pages/uploads/Westgate.dc.html +++ /dev/null @@ -1,563 +0,0 @@ - - - - - - - - - - - - - - - - -
- - - - - - -
- - -
-
- -
-
-
-
Neverwinter Nights · Persistent World
-

Welcome to Westgate

-

An ancient port city rises out of the mists, at the edge of the Sea of Fallen Stars. Mind the shadows.

-
- How to Join -
Latest News
-
-
-
- - -
-
-
-
-
-
Server Online
-
Alpha · Module 0.3
-
-
-
-
-
14 souls online
-
Peaked 32 today
-
-
-
-
Direct Connect
-
play.westgate.pw
-
- Connect Guide -
-
- - -
-
-
-
-
-
-

Long ago, merchants from distant lands filled its harbors, and gold flowed as freely as wine — but time, as it does, has a way of curdling mortal splendor.

-

Today its bones are old stone and salt-worn docks, and bargains are struck beneath every lantern's glow. Here, hoarded secrets, owed favour, and unspoken truths are the true measure of power — although coin certainly helps.

-

Westgate is a city ever-balanced on a blade's edge, built upon decaying decadence. The masked and nameless guilds clot the veins of the city with their greed and cruelty. Noble houses cling to fading legacies as their bloodlines tangle with curses, ambition, and desperation. Old cults and buried powers hold the Undergate by its throat. Beneath it all, antediluvian things begin to stir — patient, watchful, and hungry.

-
- - To walk Westgate's streets is to wager your soul in a game of chance. -
-

Every alley offers opportunity, yet each carries a price. While some come seeking fortune or power, others arrive hoping to disappear — and indeed they do, for in these boulevards you are lucky to merely survive. In Westgate, few leave unchanged… and fewer still leave at all.

-

Welcome to Westgate. Mind the shadows.

-
- - -
-
-
Your First Steps
-

Enter the City

-
- -
- - -
-
-
-
From the Watch
-

Latest News

-
-
All Dispatches →
-
-
-
-
news image
-
-
24 Jun 2026 · Announcement
-
The Gates Open — Alpha Has Begun
-

Westgate opens its harbour to its first wave of adventurers. Here is everything you need to know.

-
-
-
-
news image
-
-
21 Jun 2026 · Patch Note
-
Patch 0.3 — The Undergate
-

The tunnels beneath the city are now open. New areas, factions, and dangers await below.

-
-
-
-
news image
-
-
18 Jun 2026 · Community
-
The Community Charter
-

Our revised rules and the principles that keep Westgate's streets worth walking.

-
-
-
-
-
-
- - - -
-
-
Dispatches from the Watch
-

News

-

Official server announcements, patch notes, and word from those who keep the gates.

-
- - -
-
- feature image -
-
-
- Announcement - 24 June 2026 -
-

The Gates of Westgate Open — Alpha Has Begun

-

After long months in the shipyards, the harbour is open. Our alpha welcomes its first denizens to walk the salt-worn docks, strike bargains beneath the lanterns, and write the first chapters of Westgate's living history. Read on for what to expect, how to join, and where to begin.

-
- Read Dispatch - by The Watch · 4 min read -
-
-
- - -
- -
21
Jun 2026
-
-
Patch Note
-
Patch 0.3 — The Undergate Expansion
-

New sub-levels, the Quivering Thumb questline, balance passes to thievery, and a reworked lantern lighting system.

-
-
-
- -
18
Jun 2026
-
-
Community
-
The Community Charter & Rules Revision
-

An update to our code of conduct and roleplay standards. Please review before your next session.

-
-
-
- -
12
Jun 2026
-
-
Announcement
-
New Player Onboarding: The Watch Welcomes You
-

A streamlined path from download to your first night in the city — mentors now available on arrival.

-
-
-
- -
06
Jun 2026
-
-
Maintenance
-
Scheduled Module Reset — June 28
-

The server will be briefly unavailable for a planned reset. Expect roughly thirty minutes of downtime.

-
-
-
-
-
-
- - - -
-
-
From the Workshop
-

Developer Blog

-

Behind-the-scenes development updates — the craft, the decisions, and the long work of building a living city.

-
- -
-
- -
-
-
-
screenshot / wip render
-
-
- Worldbuilding - 22 June 2026 · 7 min read -
-

Building the Undergate: Tunnels Beneath the City

-

Westgate has always been a city of two halves — the lantern-lit streets above, and the flooded warrens below. This month we walk you through how the Undergate came together: the layout philosophy, the lighting passes that took far longer than we'd like to admit, and the factions that now call the dark home.

-
-
- archvillainette - · - Read Entry → -
-
-
-
- -
-
-
-
- Systems - 9 June 2026 · 6 min read -
-

Faction Systems & the Masked Guilds

-

Reputation, favour, and betrayal — how the guild standing system tracks the webs of loyalty players weave, and why we chose to let almost everything be undone with the right bribe.

-
-
- The Westgate Team - · - Read Entry → -
-
-
- -
-
-
-
- Art & Atmosphere - 27 May 2026 · 5 min read -
-

Lighting Westgate: A Study in Lanternlight

-

Mood is everything in a city built on shadow. A look at our approach to colour temperature, fog, and the hundreds of placed light sources that make the docks feel cold and the taverns feel like refuge.

-
-
- archvillainette - · - Read Entry → -
-
-
- -
-
-
-
- Design - 14 May 2026 · 8 min read -
-

Why We Chose Persistent Roleplay

-

The first entry. Who we are, what Shadows Over Westgate is trying to be, and the kind of stories we hope you'll tell in its streets.

-
-
- The Westgate Team - · - Read Entry → -
-
-
-
-
-
- - - -
-
-
The Archive
-

Gallery

-

Scenes from the streets of Westgate, captured by its denizens. Drag your own screenshots onto any frame.

-
- -
-
- -
The Undergate at Low Tide
@duskwarden
-
-
- -
Rain over the Harbour
@archvillainette
-
-
- -
Noble Row, House Malforten
@velvethand
-
-
- -
Masks of the Quivering Thumb
@silktongue
-
-
- -
The Tankard's Rest
@ghostofeltabbar
-
-
- -
Cobbles after Curfew
@ashen
-
-
- -
The Sea of Fallen Stars at Dusk
@cartographer
-
-
- -
A Bargain Beneath the Lantern
@nightjar
-
-
- -
Something in the Undergate
@nameless
-
-
-
-
- - - -
- - -
-
-
-
Persistent World · Made by Hand
-

Come build a world that's a pleasure to work on.

-

Westgate is made by a handful of people who love this stuff — artists, writers, builders, scripters. We've kept the tools pleasant so your time goes where it matters: into the world itself. If you make things, we'd love to meet you.

- -
-
-
- - westgate — the undergate -
-
- screenshot · a corner you'll help build -
-
-
-
- - -
-
-
Who We Need
-

Every kind of maker has a place here

-

Code, art, words, world — whatever you make, there's a part of Westgate waiting for it.

-
-
-
-
toolset · scripts
-
-

Tools that stay out of your way

-

For builders and scripters: a clean toolset and modern scripting on a fast core — none of NWScript's old quirks to fight. Everything's saved and versioned, so you can experiment without fear of breaking things.

-
-
-
-
models · textures · music
-
-

Your craft, your stamp

-

For artists and asset-makers: models, textures, areas, sound. We're not after a house style to copy — we want the thing only you'd make, and we'll help get it into the game.

-
-
-
-
lore · quests · factions
-
-

Ideas that reach players

-

For writers and designers: a questline, a faction, a forgotten corner of the city. What you dream up this month is something players are living in the next.

-
-
-
-
- - -
-
-
-
What It's Actually Like
-

Low ego, high trust — and your work goes live.

-

No gatekeeping, no hazing, no “figure it out yourself.” We give honest, kind feedback, lend a hand when it helps, and keep things moving. The thing you make this week is the thing players see this week.

-
-
-
Honest, kind feedback on your work
-
A hand whenever you want one
-
What you make can be in-game within the week
-
Work at whatever pace fits your life
-
-
-
- - -
-
-
-
-
-
-

Sound like your kind of place?

-

No test gauntlet, no résumé. Just come say hi and tell us what you'd love to build.

- - Join the Team on Discord → -
Artists · writers · builders · scripters — all welcome
-
- -
-
- - - - -
-
- - - diff --git a/custom_pages/uploads/image-slot.js b/custom_pages/uploads/image-slot.js deleted file mode 100644 index f90728a..0000000 --- a/custom_pages/uploads/image-slot.js +++ /dev/null @@ -1,643 +0,0 @@ -// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) -/* BEGIN USAGE */ -/** - * — user-fillable image placeholder. - * - * Drop this into a deck, mockup, or page wherever you want the user to - * supply an image. You control the slot's shape and size; the user fills it - * by dragging an image file onto it (or clicking to browse). The dropped - * image persists across reloads via a .image-slots.state.json sidecar — - * same read-via-fetch / write-via-window.omelette pattern as - * design_canvas.jsx, so the filled slot shows on share links, downloaded - * zips, and PPTX export. Outside the omelette runtime the slot is read-only. - * - * The host bridge only allows sidecar writes at the project root, so the - * HTML that uses this component is assumed to live at the project root too - * (same constraint as design_canvas.jsx). - * - * Attributes: - * id Persistence key. REQUIRED for the drop to survive reload — - * every slot on the page needs a distinct id. - * shape 'rect' | 'rounded' | 'circle' | 'pill' (default 'rounded') - * 'circle' applies 50% border-radius; on a non-square slot - * that's an ellipse — set equal width and height for a true - * circle. - * radius Corner radius in px for 'rounded'. (default 12) - * mask Any CSS clip-path value. Overrides `shape` — use this for - * hexagons, blobs, arbitrary polygons. - * fit object-fit: cover | contain | fill. (default 'cover') - * With cover (the default) double-clicking the filled slot - * enters a reframe mode: the whole image spills past the mask - * (translucent outside, opaque inside), drag to reposition, - * corner-drag to scale. The crop persists alongside the image - * in the sidecar. contain/fill stay static. - * position object-position for fit=contain|fill. (default '50% 50%') - * placeholder Empty-state caption. (default 'Drop an image') - * src Optional initial/fallback image URL. A user drop overrides - * it; clearing the drop reveals src again. - * - * Size and layout come from ordinary CSS on the element — width/height - * inline or from a parent grid — so it composes with any layout. - * - * Usage: - * - * - * - */ -/* END USAGE */ - -(() => { - const STATE_FILE = '.image-slots.state.json'; - // 2× a ~600px slot in a 1920-wide deck — retina-sharp without making the - // sidecar enormous. A 1200px WebP at q=0.85 is ~150-300KB. - const MAX_DIM = 1200; - // Raster formats only. SVG is excluded (can carry script; createImageBitmap - // on SVG blobs is inconsistent). GIF is excluded because the canvas - // re-encode keeps only the first frame, so an animated GIF would silently - // go still — better to reject than surprise. - const ACCEPT = ['image/png', 'image/jpeg', 'image/webp', 'image/avif']; - - // ── Shared sidecar store ──────────────────────────────────────────────── - // One fetch + immediate write-on-change for every on the - // page. Reads via fetch() so viewing works anywhere the HTML and sidecar - // are served together; writes go through window.omelette.writeFile, which - // the host allowlists to *.state.json basenames only. - const subs = new Set(); - let slots = {}; - // ids explicitly cleared before the sidecar fetch resolved — otherwise - // the merge below can't tell "never set" from "just deleted" and would - // resurrect the sidecar's stale value. - const tombstones = new Set(); - let loaded = false; - let loadP = null; - - function load() { - if (loadP) return loadP; - loadP = fetch(STATE_FILE) - .then((r) => (r.ok ? r.json() : null)) - .then((j) => { - // Merge: sidecar loses to any in-memory change that raced ahead of - // the fetch (drop or clear) so neither is clobbered by hydration. - if (j && typeof j === 'object') { - const merged = Object.assign({}, j, slots); - // A framing-only write that raced ahead of hydration must not - // drop a user image that's only on disk — inherit u from the - // sidecar for any in-memory entry that lacks one. - for (const k in slots) { - if (merged[k] && !merged[k].u && j[k]) { - merged[k].u = typeof j[k] === 'string' ? j[k] : j[k].u; - } - } - for (const id of tombstones) delete merged[id]; - slots = merged; - } - tombstones.clear(); - }) - .catch(() => {}) - .then(() => { loaded = true; subs.forEach((fn) => fn()); }); - return loadP; - } - - // Serialize writes so two near-simultaneous drops on different slots - // can't reorder at the backend and leave the sidecar with only the - // first. A save requested mid-flight just marks dirty and re-fires on - // completion with the then-current slots. - let saving = false; - let saveDirty = false; - function save() { - if (saving) { saveDirty = true; return; } - const w = window.omelette && window.omelette.writeFile; - if (!w) return; - saving = true; - Promise.resolve(w(STATE_FILE, JSON.stringify(slots))) - .catch(() => {}) - .then(() => { saving = false; if (saveDirty) { saveDirty = false; save(); } }); - } - - const S_MAX = 5; - const clampS = (s) => Math.max(1, Math.min(S_MAX, s)); - - // Normalize a stored slot value. Pre-reframe sidecars stored a bare - // data-URL string; newer ones store {u, s, x, y}. Either shape is valid. - function getSlot(id) { - const v = slots[id]; - if (!v) return null; - return typeof v === 'string' ? { u: v, s: 1, x: 0, y: 0 } : v; - } - - function setSlot(id, val) { - if (!id) return; - if (val) { slots[id] = val; tombstones.delete(id); } - else { delete slots[id]; if (!loaded) tombstones.add(id); } - subs.forEach((fn) => fn()); - // A drop is rare + high-value — write immediately so nav-away can't lose - // it. Gate on the initial read so we don't overwrite a sidecar we haven't - // merged yet; the merge in load() keeps this change once the read lands. - if (loaded) save(); else load().then(save); - } - - // ── Image downscale ───────────────────────────────────────────────────── - // Encode through a canvas so the sidecar carries resized bytes, not the - // raw upload. Longest side is capped at 2× the slot's rendered width - // (retina) and at MAX_DIM. WebP keeps alpha and is ~10× smaller than PNG - // for photos, so there's no need for per-image format picking. - async function toDataUrl(file, targetW) { - const bitmap = await createImageBitmap(file); - try { - const cap = Math.min(MAX_DIM, Math.max(1, Math.round(targetW * 2)) || MAX_DIM); - const scale = Math.min(1, cap / Math.max(bitmap.width, bitmap.height)); - const w = Math.max(1, Math.round(bitmap.width * scale)); - const h = Math.max(1, Math.round(bitmap.height * scale)); - const canvas = document.createElement('canvas'); - canvas.width = w; canvas.height = h; - canvas.getContext('2d').drawImage(bitmap, 0, 0, w, h); - return canvas.toDataURL('image/webp', 0.85); - } finally { - bitmap.close && bitmap.close(); - } - } - - // ── Custom element ────────────────────────────────────────────────────── - const stylesheet = - ':host{display:inline-block;position:relative;vertical-align:top;' + - ' font:13px/1.3 system-ui,-apple-system,sans-serif;color:rgba(0,0,0,.55);width:240px;height:160px}' + - '.frame{position:absolute;inset:0;overflow:hidden;background:rgba(0,0,0,.04)}' + - // .frame img (clipped) and .spill (unclipped ghost + handles) share the - // same left/top/width/height in frame-%, computed by _applyView(), so the - // inside-mask crop and the outside-mask spill stay pixel-aligned. - '.frame img{position:absolute;max-width:none;transform:translate(-50%,-50%);' + - ' -webkit-user-drag:none;user-select:none;touch-action:none}' + - // Reframe mode (double-click): the full image spills past the mask. The - // spill layer is sized to the IMAGE bounds so its corners are where the - // resize handles belong. The ghost inside is translucent; the real - // clipped underneath shows the opaque in-mask crop. - '.spill{position:absolute;transform:translate(-50%,-50%);display:none;z-index:1;' + - ' cursor:grab;touch-action:none}' + - ':host([data-panning]) .spill{cursor:grabbing}' + - '.spill .ghost{position:absolute;inset:0;width:100%;height:100%;opacity:.35;' + - ' pointer-events:none;-webkit-user-drag:none;user-select:none;' + - ' box-shadow:0 0 0 1px rgba(0,0,0,.2),0 12px 32px rgba(0,0,0,.2)}' + - '.spill .handle{position:absolute;width:12px;height:12px;border-radius:50%;' + - ' background:#fff;box-shadow:0 0 0 1.5px #c96442,0 1px 3px rgba(0,0,0,.3);' + - ' transform:translate(-50%,-50%)}' + - '.spill .handle[data-c=nw]{left:0;top:0;cursor:nwse-resize}' + - '.spill .handle[data-c=ne]{left:100%;top:0;cursor:nesw-resize}' + - '.spill .handle[data-c=sw]{left:0;top:100%;cursor:nesw-resize}' + - '.spill .handle[data-c=se]{left:100%;top:100%;cursor:nwse-resize}' + - ':host([data-reframe]){z-index:10}' + - ':host([data-reframe]) .spill{display:block}' + - ':host([data-reframe]) .frame{box-shadow:0 0 0 2px #c96442}' + - '.empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;' + - ' justify-content:center;gap:6px;text-align:center;padding:12px;box-sizing:border-box;' + - ' cursor:pointer;user-select:none}' + - '.empty svg{opacity:.45}' + - '.empty .cap{max-width:90%;font-weight:500;letter-spacing:.01em}' + - '.empty .sub{font-size:11px}' + - '.empty .sub u{text-underline-offset:2px;text-decoration-color:rgba(0,0,0,.25)}' + - '.empty:hover .sub u{color:rgba(0,0,0,.75);text-decoration-color:currentColor}' + - ':host([data-over]) .frame{outline:2px solid #c96442;outline-offset:-2px;' + - ' background:rgba(201,100,66,.10)}' + - '.ring{position:absolute;inset:0;pointer-events:none;border:1.5px dashed rgba(0,0,0,.25);' + - ' transition:border-color .12s}' + - ':host([data-over]) .ring{border-color:#c96442}' + - ':host([data-filled]) .ring{display:none}' + - // Controls sit BELOW the mask (top:100%), absolutely positioned so the - // author-declared slot height is unaffected. The gap is padding, not a - // top offset, so the hover target stays contiguous with the frame. - '.ctl{position:absolute;top:100%;left:50%;transform:translateX(-50%);padding-top:8px;' + - ' display:flex;gap:6px;opacity:0;pointer-events:none;transition:opacity .12s;z-index:2;' + - ' white-space:nowrap}' + - ':host([data-filled][data-editable]:hover) .ctl,:host([data-reframe]) .ctl' + - ' {opacity:1;pointer-events:auto}' + - '.ctl button{appearance:none;border:0;border-radius:6px;padding:5px 10px;cursor:pointer;' + - ' background:rgba(0,0,0,.65);color:#fff;font:11px/1 system-ui,-apple-system,sans-serif;' + - ' backdrop-filter:blur(6px)}' + - '.ctl button:hover{background:rgba(0,0,0,.8)}' + - '.err{position:absolute;left:8px;bottom:8px;right:8px;color:#b3261e;font-size:11px;' + - ' background:rgba(255,255,255,.85);padding:4px 6px;border-radius:5px;pointer-events:none}'; - - const icon = - '' + - '' + - ''; - - class ImageSlot extends HTMLElement { - static get observedAttributes() { - return ['shape', 'radius', 'mask', 'fit', 'position', 'placeholder', 'src', 'id']; - } - - constructor() { - super(); - const root = this.attachShadow({ mode: 'open' }); - // .spill and .ctl sit OUTSIDE .frame so overflow:hidden + border-radius - // on the frame (circle, pill, rounded) can't clip them. - root.innerHTML = - '' + - '
' + - ' ' + - '
' + icon + - '
' + - '
or browse files
' + - '
' + - '
' + - '
' + - ' ' + - '
' + - '
' + - '
' + - '
' + - '
' + - ''; - this._frame = root.querySelector('.frame'); - this._ring = root.querySelector('.ring'); - this._img = root.querySelector('.frame img'); - this._empty = root.querySelector('.empty'); - this._cap = root.querySelector('.cap'); - this._sub = root.querySelector('.sub'); - this._spill = root.querySelector('.spill'); - this._ghost = root.querySelector('.ghost'); - this._err = null; - this._input = root.querySelector('input'); - this._depth = 0; - this._gen = 0; - this._view = { s: 1, x: 0, y: 0 }; - this._subFn = () => this._render(); - // Shadow-DOM listeners live with the shadow DOM — bound once here so - // disconnect/reconnect (e.g. React remount) doesn't stack handlers. - this._empty.addEventListener('click', () => this._input.click()); - root.addEventListener('click', (e) => { - const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act'); - if (act === 'replace') { this._exitReframe(true); this._input.click(); } - if (act === 'clear') { - this._exitReframe(false); - this._gen++; - this._local = null; - if (this.id) setSlot(this.id, null); else this._render(); - } - }); - this._input.addEventListener('change', () => { - const f = this._input.files && this._input.files[0]; - if (f) this._ingest(f); - this._input.value = ''; - }); - // naturalWidth/Height aren't known until load — re-apply so the cover - // baseline is computed from real dimensions, not the 100%×100% fallback. - this._img.addEventListener('load', () => this._applyView()); - // Gated on editable + fit=cover so share links and contain/fill slots - // stay static. - this.addEventListener('dblclick', (e) => { - if (!this.hasAttribute('data-editable') || !this._reframes()) return; - e.preventDefault(); - if (this.hasAttribute('data-reframe')) this._exitReframe(true); - else this._enterReframe(); - }); - // Pan + resize both originate on the spill layer. A handle pointerdown - // drives an aspect-locked resize anchored at the opposite corner; any - // other pointerdown on the spill pans. Offsets are frame-% so a - // reframed slot survives responsive resize / PPTX export. - this._spill.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || !this.hasAttribute('data-reframe')) return; - e.preventDefault(); - e.stopPropagation(); - this._spill.setPointerCapture(e.pointerId); - const rect = this.getBoundingClientRect(); - const fw = rect.width || 1, fh = rect.height || 1; - const corner = e.target.getAttribute && e.target.getAttribute('data-c'); - let move; - if (corner) { - // Resize about the OPPOSITE corner. Viewport-px throughout (rect - // fw/fh, not clientWidth) so the math survives a transform:scale() - // ancestor — deck_stage renders slides scaled-to-fit. - const iw = this._img.naturalWidth || 1, ih = this._img.naturalHeight || 1; - const base = Math.max(fw / iw, fh / ih); - const sx = corner.includes('e') ? 1 : -1; - const sy = corner.includes('s') ? 1 : -1; - const s0 = this._view.s; - const w0 = iw * base * s0, h0 = ih * base * s0; - const cx0 = (50 + this._view.x) / 100 * fw; - const cy0 = (50 + this._view.y) / 100 * fh; - const ox = cx0 - sx * w0 / 2, oy = cy0 - sy * h0 / 2; - const diag0 = Math.hypot(w0, h0); - const ux = sx * w0 / diag0, uy = sy * h0 / diag0; - move = (ev) => { - const proj = (ev.clientX - rect.left - ox) * ux + - (ev.clientY - rect.top - oy) * uy; - const s = clampS(s0 * proj / diag0); - const d = diag0 * s / s0; - this._view.s = s; - this._view.x = (ox + ux * d / 2) / fw * 100 - 50; - this._view.y = (oy + uy * d / 2) / fh * 100 - 50; - this._clampView(); - this._applyView(); - }; - } else { - this.setAttribute('data-panning', ''); - const start = { px: e.clientX, py: e.clientY, x: this._view.x, y: this._view.y }; - move = (ev) => { - this._view.x = start.x + (ev.clientX - start.px) / fw * 100; - this._view.y = start.y + (ev.clientY - start.py) / fh * 100; - this._clampView(); - this._applyView(); - }; - } - const up = () => { - try { this._spill.releasePointerCapture(e.pointerId); } catch {} - this._spill.removeEventListener('pointermove', move); - this._spill.removeEventListener('pointerup', up); - this._spill.removeEventListener('pointercancel', up); - this.removeAttribute('data-panning'); - this._dragUp = null; - }; - // Stashed so _exitReframe (Escape / outside-click mid-drag) can - // tear the capture + listeners down synchronously. - this._dragUp = up; - this._spill.addEventListener('pointermove', move); - this._spill.addEventListener('pointerup', up); - this._spill.addEventListener('pointercancel', up); - }); - // Wheel zoom stays available inside reframe mode as a trackpad nicety — - // zooms toward the cursor (offset' = cursor·(1-k) + offset·k). - this.addEventListener('wheel', (e) => { - if (!this.hasAttribute('data-reframe')) return; - e.preventDefault(); - const r = this.getBoundingClientRect(); - const cx = (e.clientX - r.left) / r.width * 100 - 50; - const cy = (e.clientY - r.top) / r.height * 100 - 50; - const prev = this._view.s; - const next = clampS(prev * Math.pow(1.0015, -e.deltaY)); - if (next === prev) return; - const k = next / prev; - this._view.s = next; - this._view.x = cx * (1 - k) + this._view.x * k; - this._view.y = cy * (1 - k) + this._view.y * k; - this._clampView(); - this._applyView(); - }, { passive: false }); - } - - connectedCallback() { - // Warn once per page — an id-less slot works for the session but - // cannot persist, and two id-less slots would share nothing. - if (!this.id && !ImageSlot._warned) { - ImageSlot._warned = true; - console.warn(' without an id will not persist its dropped image.'); - } - this.addEventListener('dragenter', this); - this.addEventListener('dragover', this); - this.addEventListener('dragleave', this); - this.addEventListener('drop', this); - subs.add(this._subFn); - // width%/height% in _applyView encode the frame aspect at call time — - // a host resize (responsive grid, pane divider) would stretch the - // image until the next _render. Re-render on size change: _render() - // re-seeds _view from stored before clamp/apply, so a shrink→grow - // cycle round-trips instead of ratcheting x/y toward the narrower - // frame's clamp range. - this._ro = new ResizeObserver(() => this._render()); - this._ro.observe(this); - load(); - this._render(); - } - - disconnectedCallback() { - subs.delete(this._subFn); - this.removeEventListener('dragenter', this); - this.removeEventListener('dragover', this); - this.removeEventListener('dragleave', this); - this.removeEventListener('drop', this); - if (this._ro) { this._ro.disconnect(); this._ro = null; } - this._exitReframe(false); - } - - _enterReframe() { - if (this.hasAttribute('data-reframe')) return; - this.setAttribute('data-reframe', ''); - this._applyView(); - // Close on click outside (the spill handler stopPropagation()s so - // in-image drags don't reach this) and on Escape. Listeners are held - // on the instance so _exitReframe / disconnectedCallback can detach - // exactly what was attached. - this._outside = (e) => { - if (e.composedPath && e.composedPath().includes(this)) return; - this._exitReframe(true); - }; - this._esc = (e) => { if (e.key === 'Escape') this._exitReframe(true); }; - document.addEventListener('pointerdown', this._outside, true); - document.addEventListener('keydown', this._esc, true); - } - - _exitReframe(commit) { - if (!this.hasAttribute('data-reframe')) return; - if (this._dragUp) this._dragUp(); - this.removeAttribute('data-reframe'); - this.removeAttribute('data-panning'); - if (this._outside) document.removeEventListener('pointerdown', this._outside, true); - if (this._esc) document.removeEventListener('keydown', this._esc, true); - this._outside = this._esc = null; - if (commit) this._commitView(); - } - - attributeChangedCallback() { if (this.shadowRoot) this._render(); } - - // handleEvent — one listener object for all four drag events keeps the - // add/remove symmetric and the depth counter correct. - handleEvent(e) { - if (e.type === 'dragenter' || e.type === 'dragover') { - // Without preventDefault the browser never fires 'drop'. - e.preventDefault(); - e.stopPropagation(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; - if (e.type === 'dragenter') this._depth++; - this.setAttribute('data-over', ''); - } else if (e.type === 'dragleave') { - // dragenter/leave fire for every descendant crossing — count depth - // so hovering the icon inside the empty state doesn't flicker. - if (--this._depth <= 0) { this._depth = 0; this.removeAttribute('data-over'); } - } else if (e.type === 'drop') { - e.preventDefault(); - e.stopPropagation(); - this._depth = 0; - this.removeAttribute('data-over'); - const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; - if (f) this._ingest(f); - } - } - - async _ingest(file) { - this._setError(null); - if (!file || ACCEPT.indexOf(file.type) < 0) { - this._setError('Drop a PNG, JPEG, WebP, or AVIF image.'); - return; - } - // toDataUrl can take hundreds of ms on a large photo. A Clear or a - // newer drop during that window would be clobbered when this await - // resumes — bump + capture a generation so stale encodes bail. - const gen = ++this._gen; - try { - const w = this.clientWidth || this.offsetWidth || MAX_DIM; - const url = await toDataUrl(file, w); - if (gen !== this._gen) return; - // Only exit reframe once the new image is in hand — a rejected type - // or decode failure leaves the in-progress crop untouched. - this._exitReframe(false); - const val = { u: url, s: 1, x: 0, y: 0 }; - setSlot(this.id || '', val); - // Keep a session-local copy for id-less slots so the drop still - // shows, even though it cannot persist. - if (!this.id) { this._local = val; this._render(); } - } catch (err) { - if (gen !== this._gen) return; - this._setError('Could not read that image.'); - console.warn(' ingest failed:', err); - } - } - - _setError(msg) { - if (this._err) { this._err.remove(); this._err = null; } - if (!msg) return; - const d = document.createElement('div'); - d.className = 'err'; d.textContent = msg; - this.shadowRoot.appendChild(d); - this._err = d; - setTimeout(() => { if (this._err === d) { d.remove(); this._err = null; } }, 3000); - } - - // Reframing (pan/resize) is only meaningful for fit=cover — contain/fill - // keep the old object-fit path and double-click is a no-op. - _reframes() { - return this.hasAttribute('data-filled') && - (this.getAttribute('fit') || 'cover') === 'cover'; - } - - // Cover-baseline geometry, shared by clamp/apply/resize. Null until the - // img has loaded (naturalWidth is 0 before that) or when the slot has no - // layout box — ResizeObserver fires with a 0×0 rect under display:none, - // and clamping against a degenerate 1×1 frame would silently pull the - // stored pan toward zero. - _geom() { - const iw = this._img.naturalWidth, ih = this._img.naturalHeight; - const fw = this.clientWidth, fh = this.clientHeight; - if (!iw || !ih || !fw || !fh) return null; - return { iw, ih, fw, fh, base: Math.max(fw / iw, fh / ih) }; - } - - _clampView() { - // Pan range on each axis is half the overflow past the frame edge. - const g = this._geom(); - if (!g) return; - const mx = Math.max(0, (g.iw * g.base * this._view.s / g.fw - 1) * 50); - const my = Math.max(0, (g.ih * g.base * this._view.s / g.fh - 1) * 50); - this._view.x = Math.max(-mx, Math.min(mx, this._view.x)); - this._view.y = Math.max(-my, Math.min(my, this._view.y)); - } - - _applyView() { - const g = this._geom(); - const fit = this.getAttribute('fit') || 'cover'; - if (fit !== 'cover' || !g) { - // Non-cover, or dimensions not known yet (before img load). - this._img.style.width = '100%'; - this._img.style.height = '100%'; - this._img.style.left = '50%'; - this._img.style.top = '50%'; - this._img.style.objectFit = fit; - this._img.style.objectPosition = this.getAttribute('position') || '50% 50%'; - return; - } - // Cover baseline: img fills the frame on its tighter axis at s=1, so - // pan works immediately on the overflowing axis without zooming first. - // Width/height and left/top are all frame-% — depends only on the - // frame aspect ratio, so a responsive resize keeps the same crop. The - // spill layer mirrors the same box so its corners = image corners. - const k = g.base * this._view.s; - const w = (g.iw * k / g.fw * 100) + '%'; - const h = (g.ih * k / g.fh * 100) + '%'; - const l = (50 + this._view.x) + '%'; - const t = (50 + this._view.y) + '%'; - this._img.style.width = w; this._img.style.height = h; - this._img.style.left = l; this._img.style.top = t; - this._img.style.objectFit = ''; - this._spill.style.width = w; this._spill.style.height = h; - this._spill.style.left = l; this._spill.style.top = t; - } - - _commitView() { - const v = { s: this._view.s, x: this._view.x, y: this._view.y }; - if (this._userUrl) v.u = this._userUrl; - // Framing-only (no u) persists too so an author-src slot remembers its - // crop; clearing the sidecar still falls through to src=. - if (this.id) setSlot(this.id, v); - else { this._local = v; } - } - - _render() { - // Shape / mask. Presets use border-radius so the dashed ring can - // follow the rounded outline; clip-path is only applied for an - // explicit `mask` (the ring is hidden there since a rectangle - // dashed border chopped by an arbitrary polygon looks broken). - const mask = this.getAttribute('mask'); - const shape = (this.getAttribute('shape') || 'rounded').toLowerCase(); - let radius = ''; - if (shape === 'circle') radius = '50%'; - else if (shape === 'pill') radius = '9999px'; - else if (shape === 'rounded') { - const n = parseFloat(this.getAttribute('radius')); - radius = (Number.isFinite(n) ? n : 12) + 'px'; - } - this._frame.style.borderRadius = mask ? '' : radius; - this._frame.style.clipPath = mask || ''; - this._ring.style.borderRadius = mask ? '' : radius; - this._ring.style.display = mask ? 'none' : ''; - - // Controls and reframe entry gate on this so share links stay read-only. - const editable = !!(window.omelette && window.omelette.writeFile); - this.toggleAttribute('data-editable', editable); - this._sub.style.display = editable ? '' : 'none'; - - // Content. The sidecar is also writable by the agent's write_file - // tool, so its value isn't guaranteed canvas-originated — only accept - // data:image/ URLs from it. The `src` attribute is author-controlled - // (Claude wrote it into the HTML) so it passes through unchanged. - let stored = this.id ? getSlot(this.id) : this._local; - if (stored && stored.u && !/^data:image\//i.test(stored.u)) stored = null; - const srcAttr = this.getAttribute('src') || ''; - this._userUrl = (stored && stored.u) || null; - const url = this._userUrl || srcAttr; - // Don't clobber an in-flight reframe with a store-triggered re-render. - if (!this.hasAttribute('data-reframe')) { - this._view = { - s: stored && Number.isFinite(stored.s) ? clampS(stored.s) : 1, - x: stored && Number.isFinite(stored.x) ? stored.x : 0, - y: stored && Number.isFinite(stored.y) ? stored.y : 0, - }; - } - this._cap.textContent = this.getAttribute('placeholder') || 'Drop an image'; - // Toggle via style.display — the [hidden] attribute alone loses to - // the display:flex / display:block rules in the stylesheet above. - if (url) { - if (this._img.getAttribute('src') !== url) { - this._img.src = url; - this._ghost.src = url; - } - this._img.style.display = 'block'; - this._empty.style.display = 'none'; - this.setAttribute('data-filled', ''); - this._clampView(); - this._applyView(); - } else { - this._img.style.display = 'none'; - this._img.removeAttribute('src'); - this._ghost.removeAttribute('src'); - this._empty.style.display = 'flex'; - this.removeAttribute('data-filled'); - } - } - } - - if (!customElements.get('image-slot')) { - customElements.define('image-slot', ImageSlot); - } -})(); diff --git a/custom_pages/uploads/pasted-1782453560502-0.png b/custom_pages/uploads/pasted-1782453560502-0.png deleted file mode 100644 index 8a77911..0000000 Binary files a/custom_pages/uploads/pasted-1782453560502-0.png and /dev/null differ diff --git a/custom_pages/uploads/pasted-1782453868405-0.png b/custom_pages/uploads/pasted-1782453868405-0.png deleted file mode 100644 index 10325fa..0000000 Binary files a/custom_pages/uploads/pasted-1782453868405-0.png and /dev/null differ diff --git a/custom_pages/uploads/pasted-1782454476832-0.png b/custom_pages/uploads/pasted-1782454476832-0.png deleted file mode 100644 index 080c1e7..0000000 Binary files a/custom_pages/uploads/pasted-1782454476832-0.png and /dev/null differ diff --git a/custom_pages/uploads/screenshots-1782406408514.png b/custom_pages/uploads/screenshots-1782406408514.png deleted file mode 100644 index 5db04ba..0000000 Binary files a/custom_pages/uploads/screenshots-1782406408514.png and /dev/null differ diff --git a/custom_pages/uploads/support.js b/custom_pages/uploads/support.js deleted file mode 100644 index 438a270..0000000 --- a/custom_pages/uploads/support.js +++ /dev/null @@ -1,1583 +0,0 @@ -// GENERATED from dc-runtime/src/*.ts — do not edit. Rebuild with `cd dc-runtime && bun run build`. -"use strict"; -(() => { - var __defProp = Object.defineProperty; - var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; - var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); - - // src/react.ts - function getReact() { - const R = window.React; - if (!R) throw new Error("dc-runtime: window.React is not available yet"); - return R; - } - function getReactDOM() { - const RD = window.ReactDOM; - if (!RD) throw new Error("dc-runtime: window.ReactDOM is not available yet"); - return RD; - } - var h = ((...args) => getReact().createElement( - ...args - )); - - // src/parse.ts - function parseDcDocument(doc) { - const dc = doc.querySelector("x-dc"); - if (!dc) return null; - const scriptEl = doc.querySelector("script[data-dc-script]"); - const { props, preview } = parseDataProps( - scriptEl?.getAttribute("data-props") ?? null - ); - return { - template: dc.innerHTML, - js: scriptEl ? scriptEl.textContent || "" : "", - props, - preview - }; - } - function parseDcText(src) { - const openMatch = /]*)?>/.exec(src); - if (!openMatch) return null; - const close = src.lastIndexOf(""); - if (close === -1 || close < openMatch.index) return null; - const template = src.slice(openMatch.index + openMatch[0].length, close); - const doc = new DOMParser().parseFromString(src, "text/html"); - const scriptEl = doc.querySelector("script[data-dc-script]"); - const { props, preview } = parseDataProps( - scriptEl?.getAttribute("data-props") ?? null - ); - return { - template, - js: scriptEl ? scriptEl.textContent || "" : "", - props, - preview - }; - } - function parseDataProps(raw) { - if (!raw) return { props: null, preview: null }; - let parsed; - try { - parsed = JSON.parse(raw); - } catch { - return { props: null, preview: null }; - } - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return { props: null, preview: null }; - } - const obj = parsed; - const preview = obj.$preview && typeof obj.$preview === "object" ? obj.$preview : null; - const rest = {}; - for (const k of Object.keys(obj)) { - if (k[0] !== "$") rest[k] = obj[k]; - } - return { props: Object.keys(rest).length ? rest : null, preview }; - } - function dcNameFromPath(pathname) { - let p = pathname || ""; - try { - p = decodeURIComponent(p); - } catch { - } - const base = p.split("/").pop() || "Root"; - return base.replace(/\.dc\.html$/, "").replace(/\.html?$/, "") || "Root"; - } - - // src/boot.ts - var BASE_CSS = ` - .sc-placeholder{background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); - border-radius:2px;box-sizing:border-box;overflow:hidden} - @keyframes sc-shine{0%{background-position:100% 50%}100%{background-position:0% 50%}} - html.sc-dc-streaming .sc-placeholder, - html.sc-dc-streaming .sc-interp.sc-missing{position:relative; - background:color-mix(in srgb,currentColor 5%,transparent); - border-color:transparent} - html.sc-dc-streaming .sc-placeholder::before, - html.sc-dc-streaming .sc-interp.sc-missing::before{content:''; - position:absolute;inset:0;pointer-events:none; - background:linear-gradient(90deg,rgba(217,119,87,0) 25%,rgba(247,225,211,.95) 37%,rgba(217,119,87,0) 63%); - background-size:400% 100%;animation:sc-shine 1.4s ease infinite} - html.sc-dc-streaming .sc-placeholder:nth-child(n+9 of .sc-placeholder)::before, - html.sc-dc-streaming .sc-interp.sc-missing:nth-child(n+9 of .sc-interp.sc-missing)::before{animation:none; - background:color-mix(in srgb,currentColor 8%,transparent)} - .sc-placeholder-error{padding:4px 8px;font:11px/1.4 ui-monospace,monospace; - color:rgba(0,0,0,.7);word-break:break-word} - .sc-interp.sc-missing{display:inline-block;width:2em;height:1em;overflow:hidden; - vertical-align:text-bottom;background:rgba(255,255,255,.3);border:1px solid rgba(0,0,0,.5); - border-radius:2px;box-sizing:border-box;color:transparent; - user-select:none} - .sc-interp.sc-unresolved{font-family:ui-monospace,monospace;font-size:.85em; - color:rgba(0,0,0,.5);background:rgba(0,0,0,.05);border-radius:3px; - padding:0 3px} - .sc-host.sc-has-error{position:relative} - .sc-logic-error{position:absolute;top:8px;left:8px;z-index:2147483647;max-width:60ch; - padding:6px 10px;background:#b00020;color:#fff;font:12px/1.4 ui-monospace,monospace; - border-radius:4px;white-space:pre-wrap;pointer-events:none} - /* Mirrors PRINT_BASELINE_CSS in apps/web deck-stage-export.ts \u2014 keep both - in sync until dc-runtime regains a build step. */ - @media print { - @page { margin: 0.5cm; } - figure, table { break-inside: avoid; } - #dc-root, #dc-root > .sc-host { height: auto; } - *, *::before, *::after { - print-color-adjust: exact; -webkit-print-color-adjust: exact; - backdrop-filter: none !important; -webkit-backdrop-filter: none !important; - animation-delay: -99s !important; animation-duration: .001s !important; - animation-iteration-count: 1 !important; animation-fill-mode: both !important; - animation-play-state: running !important; transition-duration: 0s !important; - } - } - `; - var FULL_PAGE_CSS = "html,body{height:100%;margin:0}#dc-root,#dc-root>.sc-host{height:100%}"; - function rootNameForDocument(doc, loc) { - let bootPath = loc.pathname || ""; - if (!/\.dc\.html?$/i.test(safeDecode(bootPath))) { - try { - bootPath = new URL(doc.baseURI || "/").pathname; - } catch { - } - } - return dcNameFromPath(bootPath); - } - function safeDecode(s) { - try { - return decodeURIComponent(s); - } catch { - return s; - } - } - function boot(runtime, doc = document) { - const parsed = parseDcDocument(doc); - if (!parsed) return null; - const React = getReact(); - const rootName = rootNameForDocument(doc, location); - runtime.markFetched(rootName); - runtime.setRootName(rootName); - runtime.adoptParsed(rootName, parsed); - fetch(location.href).then((res) => res.ok ? res.text() : "").then((t) => { - const raw = t ? parseDcText(t) : null; - if (raw?.template) runtime.updateHtml(rootName, raw.template); - }).catch(() => { - }); - const dc = doc.querySelector("x-dc"); - const hostEl = doc.createElement("div"); - hostEl.id = "dc-root"; - dc.replaceWith(hostEl); - if (!parsed.preview) { - const s = doc.createElement("style"); - s.textContent = FULL_PAGE_CSS; - doc.head.appendChild(s); - } - const Root = runtime.getDC(rootName); - const entry = runtime.registry.get(rootName); - function StandaloneRoot() { - const [, setTick] = React.useState(0); - React.useEffect(() => { - const sub = () => setTick((n) => n + 1); - entry.subs.add(sub); - return () => { - entry.subs.delete(sub); - }; - }, []); - return h(Root, entry.propOverrides || null); - } - const ReactDOM = getReactDOM(); - if (ReactDOM.createRoot) - ReactDOM.createRoot(hostEl).render(h(StandaloneRoot)); - else ReactDOM.render(h(StandaloneRoot), hostEl); - return rootName; - } - - // src/expr.ts - var IDENT_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; - var NUMBER_RE = /^-?\d+(\.\d+)?$/; - function resolve(vals, src) { - const expr = String(src).trim(); - if (!expr) return void 0; - if (expr[0] === "(" && expr[expr.length - 1] === ")" && parensWrapWhole(expr)) { - return resolve(vals, expr.slice(1, -1)); - } - const eq = findTopLevelEquality(expr); - if (eq) { - const lv = resolve(vals, expr.slice(0, eq.index)); - const rv = resolve(vals, expr.slice(eq.index + eq.op.length)); - switch (eq.op) { - case "===": - return lv === rv; - case "!==": - return lv !== rv; - case "==": - return lv == rv; - default: - return lv != rv; - } - } - if (expr[0] === "!") return !resolve(vals, expr.slice(1)); - if (expr === "true") return true; - if (expr === "false") return false; - if (expr === "null") return null; - if (expr === "undefined") return void 0; - if (NUMBER_RE.test(expr)) return Number(expr); - if (expr.length >= 2 && (expr[0] === '"' || expr[0] === "'") && expr[expr.length - 1] === expr[0]) { - return expr.slice(1, -1); - } - return resolvePath(vals, expr); - } - function parensWrapWhole(expr) { - let depth = 0; - for (let i = 0; i < expr.length - 1; i++) { - if (expr[i] === "(") depth++; - else if (expr[i] === ")") { - depth--; - if (depth === 0) return false; - } - } - return true; - } - function findTopLevelEquality(expr) { - let depth = 0; - for (let i = 0; i < expr.length; i++) { - const c = expr[i]; - if (c === "[" || c === "(") depth++; - else if (c === "]" || c === ")") depth--; - else if (depth === 0 && (c === "=" || c === "!") && expr[i + 1] === "=") { - if (i > 0 && (expr[i - 1] === "=" || expr[i - 1] === "!")) continue; - if (!expr.slice(0, i).trim()) continue; - const op = expr[i + 2] === "=" ? c + "==" : c + "="; - return { index: i, op }; - } - } - return null; - } - function resolvePath(vals, expr) { - const head = expr.match(IDENT_RE); - if (!head) return void 0; - let cur = vals == null ? void 0 : vals[head[0]]; - let i = head[0].length; - while (i < expr.length) { - if (expr[i] === ".") { - const m = expr.slice(i + 1).match(IDENT_RE) || expr.slice(i + 1).match(/^\d+/); - if (!m) return void 0; - cur = cur == null ? void 0 : cur[m[0]]; - i += 1 + m[0].length; - } else if (expr[i] === "[") { - let depth = 1; - let j = i + 1; - while (j < expr.length && depth > 0) { - if (expr[j] === "[") depth++; - else if (expr[j] === "]") { - depth--; - if (depth === 0) break; - } - j++; - } - if (depth !== 0) return void 0; - const key = resolve(vals, expr.slice(i + 1, j)); - cur = cur == null ? void 0 : cur[key]; - i = j + 1; - } else { - return void 0; - } - } - return cur; - } - - // src/encode.ts - var CAMEL_ATTR = "sc-camel-"; - var RAW_WRAP = { - select: "sc-raw-select", - table: "sc-raw-table", - tbody: "sc-raw-tbody", - thead: "sc-raw-thead", - tfoot: "sc-raw-tfoot", - tr: "sc-raw-tr", - td: "sc-raw-td", - th: "sc-raw-th", - caption: "sc-raw-caption" - }; - var RAW_UNWRAP = Object.fromEntries( - Object.entries(RAW_WRAP).map(([k, v]) => [v, k]) - ); - var EVENT_MAP = { - onclick: "onClick", - onchange: "onChange", - oninput: "onInput", - onsubmit: "onSubmit", - onkeydown: "onKeyDown", - onkeyup: "onKeyUp", - onkeypress: "onKeyPress", - onmousedown: "onMouseDown", - onmouseup: "onMouseUp", - onmouseenter: "onMouseEnter", - onmouseleave: "onMouseLeave", - onfocus: "onFocus", - onblur: "onBlur", - ondoubleclick: "onDoubleClick", - oncontextmenu: "onContextMenu" - }; - var ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`; - var IMPORT_SELF_CLOSE_RE = new RegExp( - "<(x-import|dc-import)(" + ATTRS + ")/>", - "gi" - ); - var CAMEL_ATTR_RE = /(\s)([a-z]+[A-Z][A-Za-z0-9]*)(\s*=)/g; - function encodeCase(html) { - html = html.replace( - IMPORT_SELF_CLOSE_RE, - (_, t, a) => "<" + t + a + ">" - ); - html = html.replace(/)/gi, "/gi, ""); - html = html.replace( - CAMEL_ATTR_RE, - (_, sp, name, eq) => sp + CAMEL_ATTR + name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()) + eq - ); - for (const [real, alias] of Object.entries(RAW_WRAP)) { - html = html.replace( - new RegExp("(])", "gi"), - "$1" + alias - ); - } - return html; - } - function kebabToCamel(s) { - return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); - } - function cssToObj(css) { - const o = {}; - for (const decl of css.split(";")) { - const i = decl.indexOf(":"); - if (i < 0) continue; - const prop = decl.slice(0, i).trim(); - o[prop.startsWith("--") ? prop : kebabToCamel(prop)] = decl.slice(i + 1).trim(); - } - return o; - } - function compileAttr(raw) { - const whole = raw.match(/^\s*\{\{([\s\S]+?)\}\}\s*$/); - if (whole) { - const path = whole[1]; - return (vals) => resolve(vals, path); - } - if (raw.includes("{{")) { - const parts = raw.split(/\{\{([\s\S]+?)\}\}/g); - return (vals) => parts.map((s, i) => i & 1 ? resolve(vals, s) ?? "" : s).join(""); - } - return () => raw; - } - - // src/compile.ts - function collectProps(node, kind, host) { - const propGetters = []; - const pseudoClasses = []; - let hintSize = null; - for (const { name, value } of [...node.attributes]) { - if (name === "sc-name" || name === "data-dc-tpl") continue; - let key = name; - if (key.startsWith(CAMEL_ATTR)) - key = kebabToCamel(key.slice(CAMEL_ATTR.length)); - if (key === "hint-size") { - hintSize = value; - continue; - } - if (key.startsWith("style-")) { - pseudoClasses.push(host.pseudoClass(key.slice(6), value)); - continue; - } - if (kind !== "dom") { - if (key.includes("-") && !(kind === "x-import" && (key.startsWith("aria-") || key.startsWith("data-")))) - key = kebabToCamel(key); - } else { - if (key === "class") key = "className"; - else if (key === "for") key = "htmlFor"; - else if (key.startsWith("on")) - key = EVENT_MAP[key] || "on" + key[2].toUpperCase() + key.slice(3); - } - propGetters.push([key, compileAttr(value)]); - } - return { propGetters, pseudoClasses, hintSize }; - } - var HOST_STYLE_PROPS = /* @__PURE__ */ new Set([ - "position", - "left", - "right", - "top", - "bottom", - "inset", - "width", - "height", - "z-index", - "transform" - ]); - function hostPositionStyle(style) { - const all = typeof style === "string" ? cssToObj(style) : style != null && typeof style === "object" ? style : null; - if (!all) return void 0; - const out = {}; - for (const [k, v] of Object.entries(all)) { - const kebab = k.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); - if (HOST_STYLE_PROPS.has(kebab)) out[k] = v; - } - return Object.keys(out).length ? out : void 0; - } - function compileTemplate(html, host) { - const tpl = document.createElement("template"); - //! nosemgrep: direct-inner-html-assignment - tpl.innerHTML = encodeCase(html); - let tplN = 0; - (function stamp(node) { - if (node.nodeType === Node.ELEMENT_NODE) { - node.setAttribute("data-dc-tpl", String(tplN++)); - } - for (const c of node.childNodes) stamp(c); - })(tpl.content); - const builders = walkChildren(tpl.content, host); - const render = ((vals, ctx) => builders.map((b, i) => b(vals || {}, ctx, i))); - render.__annotated = tpl.innerHTML; - return render; - } - function walkChildren(node, host) { - return [...node.childNodes].map((c) => walk(c, host)).filter((b) => b != null); - } - function walk(node, host) { - if (node.nodeType === Node.TEXT_NODE) return walkText(node); - if (node.nodeType !== Node.ELEMENT_NODE) return null; - const el = node; - const tag = el.tagName.toLowerCase(); - if (tag === "sc-for") return walkFor(el, host); - if (tag === "sc-if") return walkIf(el, host); - if (tag === "x-import") return walkXImport(el, host); - if (tag === "sc-helmet") return host.helmet(el); - if (tag === "dc-import") return walkComponent(el, host); - return walkElement(el, host); - } - var warnedHoles = /* @__PURE__ */ new Set(); - function warnUnresolved(ctx, what) { - const key = (ctx?.__name || "?") + "\0" + what; - if (warnedHoles.has(key)) return; - warnedHoles.add(key); - console.warn("[dc-runtime] " + (ctx?.__name || "template") + ": " + what); - } - function walkText(node) { - const txt = node.nodeValue ?? ""; - if (!txt.includes("{{")) { - if (!txt.trim() && !txt.includes(" ")) return null; - return () => txt; - } - const parts = txt.split(/\{\{([\s\S]+?)\}\}/g); - return (vals, ctx, key) => h( - getReact().Fragment, - { key }, - ...parts.map((p, i) => { - if (!(i & 1)) return p; - const v = resolve(vals, p); - if (v === void 0) { - if (!ctx?.__streamingNow) { - if (document.body?.hasAttribute("data-dc-editor-on")) { - return h( - "span", - { key: i, className: "sc-interp sc-unresolved" }, - "{{ " + p.trim() + " }}" - ); - } - warnUnresolved( - ctx, - "{{ " + p.trim() + " }} never resolved \u2014 rendered as empty" - ); - return null; - } - return h( - "span", - { key: i, className: "sc-interp sc-missing" }, - p.trim() - ); - } - if (getReact().isValidElement(v) || Array.isArray(v)) { - return h(getReact().Fragment, { key: i }, v); - } - if (v === null || typeof v === "boolean") return null; - return h("span", { key: i, className: "sc-interp" }, String(v)); - }) - ); - } - function walkFor(el, host) { - const listGet = compileAttr(el.getAttribute("list") || ""); - const asName = el.getAttribute("as") || "item"; - const hintN = parseInt(el.getAttribute("hint-placeholder-count") || "0", 10); - const kids = walkChildren(el, host); - const listSrc = el.getAttribute("list") || ""; - return (vals, ctx, key) => { - let list = listGet(vals); - if (!Array.isArray(list)) { - if (!ctx?.__streamingNow) { - if (list !== void 0 && list !== null) { - warnUnresolved( - ctx, - 'sc-for list="' + listSrc + '" is not an array (' + typeof list + ")" - ); - } - list = []; - } else { - list = hintN > 0 ? Array(hintN).fill(void 0) : []; - } - } - return h( - getReact().Fragment, - { key }, - list.map((item, i) => { - const sub = { ...vals, [asName]: item, $index: i }; - return h( - getReact().Fragment, - { key: i }, - kids.map((b, j) => b(sub, ctx, j)) - ); - }) - ); - }; - } - function walkIf(el, host) { - const valGet = compileAttr(el.getAttribute("value") || ""); - const hintRaw = el.getAttribute("hint-placeholder-val"); - const hintGet = hintRaw != null ? compileAttr(hintRaw) : null; - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - let v = valGet(vals); - if (v === void 0 && hintGet && ctx?.__streamingNow) v = hintGet(vals); - return v ? h( - getReact().Fragment, - { key }, - kids.map((b, j) => b(vals, ctx, j)) - ) : null; - }; - } - function walkComponent(el, host) { - const name = el.getAttribute("name") || el.getAttribute("component") || ""; - el.removeAttribute("name"); - el.removeAttribute("component"); - const tplId = el.getAttribute("data-dc-tpl"); - const styleRaw = el.getAttribute("style"); - el.removeAttribute("style"); - const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; - const { propGetters, hintSize } = collectProps(el, "dc-import", host); - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - const props = { - key, - __hintSize: hintSize, - __tplId: tplId, - __hostStyle: styleGet ? hostPositionStyle(styleGet(vals)) : void 0 - }; - for (const [k, g] of propGetters) { - const v = g(vals); - if (k === "dcProps") { - if (v && typeof v === "object") Object.assign(props, v); - continue; - } - props[k] = v; - } - if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); - return h(host.component(name), props); - }; - } - function walkXImport(el, host) { - const globalNameGet = compileAttr( - el.getAttribute("component-from-global-scope") || "" - ); - const exportNameGet = compileAttr( - el.getAttribute("component") || el.getAttribute("name") || "" - ); - const url = el.getAttribute("from") || el.getAttribute("src") || el.getAttribute("import") || ""; - const kind = /\.(jsx|tsx)(\?|#|$)/i.test(url) ? "jsx" : "js"; - const tplId = el.getAttribute("data-dc-tpl"); - const styleRaw = el.getAttribute("style"); - el.removeAttribute("style"); - const styleGet = styleRaw != null ? compileAttr(styleRaw) : null; - const wrap = tplId != null || styleGet != null; - const { propGetters, hintSize } = collectProps(el, "x-import", host); - const hasContent = el.children.length > 0 || !!(el.textContent || "").trim(); - const kids = hasContent ? walkChildren(el, host) : []; - const urlBindable = url.includes("{{"); - if (url && !urlBindable) host.loadExternal(kind, url); - const evalName = (g, vals) => { - const v = g(vals); - const s = v == null ? "" : String(v); - return s.includes("{{") ? "" : s; - }; - return (vals, ctx, key) => { - const globalName = evalName(globalNameGet, vals); - const name = globalName || evalName(exportNameGet, vals); - const C = !name || urlBindable ? null : globalName ? host.resolveExternalGlobal(url, globalName) : host.resolveExternal(url, name); - const hostStyle = styleGet ? hostPositionStyle(styleGet(vals)) : void 0; - const wrapper = wrap ? { - key, - className: "sc-host-x", - "data-dc-tpl": tplId, - style: hostStyle || { display: "contents" } - } : null; - if (!C) { - const error = urlBindable ? "x-import `from` cannot contain {{ \u2026 }} \u2014 module URLs are resolved at parse time; use a literal URL" : host.resolveExternalError(url, name); - const ph = host.placeholder({ - key: wrapper ? void 0 : key, - name, - hintSize, - error - }); - return wrapper ? h("div", wrapper, ph) : ph; - } - const props = wrapper ? {} : { key }; - let unresolvedHole = false; - for (const [k, g] of propGetters) { - if (k === "component" || k === "componentFromGlobalScope" || k === "from") { - continue; - } - const v = g(vals); - if (v === void 0) unresolvedHole = true; - if (k === "dcProps") { - if (v && typeof v === "object") Object.assign(props, v); - continue; - } - props[k] = v; - } - if (unresolvedHole && ctx?.__htmlStreamingNow) { - const ph = host.placeholder({ - key: wrapper ? void 0 : key, - name, - hintSize, - error: null - }); - return wrapper ? h("div", wrapper, ph) : ph; - } - if (kids.length) props.children = kids.map((b, j) => b(vals, ctx, j)); - return wrapper ? h("div", wrapper, h(C, props)) : h(C, props); - }; - } - function walkElement(el, host) { - const realTag = RAW_UNWRAP[el.localName] || el.localName; - const tplId = el.getAttribute("data-dc-tpl"); - const { propGetters, pseudoClasses } = collectProps(el, "dom", host); - const kids = walkChildren(el, host); - return (vals, ctx, key) => { - const props = { key, "data-dc-tpl": tplId }; - for (const [k, g] of propGetters) { - let v = g(vals); - if (k === "style" && typeof v === "string") v = cssToObj(v); - if ((k === "value" || k === "checked") && v === void 0) { - v = k === "checked" ? false : ""; - } - props[k] = v; - } - if (pseudoClasses.length) { - props.className = [props.className, ...pseudoClasses].filter(Boolean).join(" "); - } - return h(realTag, props, ...kids.map((b, j) => b(vals, ctx, j))); - }; - } - - // src/logic.ts - var StreamableLogic = class { - constructor(props) { - __publicField(this, "props"); - __publicField(this, "state", {}); - /** Back-pointer to the wrapper component, installed after construction. */ - __publicField(this, "__host"); - this.props = props || {}; - } - setState(update, cb) { - this.__host && this.__host.__setLogicState(update, cb); - } - forceUpdate() { - this.__host && this.__host.forceUpdate(); - } - componentDidMount() { - } - componentDidUpdate(_prevProps) { - } - componentWillUnmount() { - } - /** The flat object the template renders against (merged over props). */ - renderVals() { - return {}; - } - }; - function evalDcLogic(src) { - //! nosemgrep: eval-and-function-constructor - const fn = new Function( - "DCLogic", - "StreamableLogic", - "React", - src + '\n;return (typeof Component!=="undefined"&&Component)||undefined;' - ); - return fn(StreamableLogic, StreamableLogic, getReact()); - } - - // src/component.ts - function shallowEqual(a, b) { - if (!b) return false; - const ak = Object.keys(a).filter((k) => k !== "children"); - const bk = Object.keys(b).filter((k) => k !== "children"); - if (ak.length !== bk.length) return false; - for (const k of ak) if (a[k] !== b[k]) return false; - return true; - } - function Placeholder({ - name, - hintSize, - streaming, - error - }) { - const [w, hgt] = (hintSize || "100%,60px").split(","); - return h( - "div", - { - className: "sc-placeholder" + (streaming ? " sc-streaming" : ""), - style: { width: w.trim(), height: hgt && hgt.trim() }, - title: name - }, - error ? h( - "div", - { className: "sc-placeholder-error" }, - (name ? name + ": " : "") + error - ) : null - ); - } - function hintToMin(hint) { - if (!hint) return void 0; - const [w, hgt] = hint.split(","); - return { minWidth: w.trim(), minHeight: hgt && hgt.trim() }; - } - function createComponentFactory(registry, ensureFetched) { - const React = getReact(); - const AncestorContext = React.createContext([]); - class StreamableComponent extends React.Component { - constructor(props) { - super(props); - __publicField(this, "__name"); - __publicField(this, "__sub"); - __publicField(this, "__needsDidMount", false); - /** Snapshot of the registry's streaming flags taken at render time — - * builders read it off the RenderCtx (this) to pick placeholder vs - * render-nothing for unresolved values. */ - __publicField(this, "__streamingNow", false); - __publicField(this, "__htmlStreamingNow", false); - /** When a construct throws, remember the (class, registry.ver, props) - * triple so render-time reconcile doesn't re-attempt it on every parent - * re-render. A registry bump (new class, template, external module - * resolving via bumpAll) changes `ver` and breaks the memo so an - * env-dependent constructor can self-heal. */ - __publicField(this, "__failedLogic", null); - __publicField(this, "__failedUserProps", null); - __publicField(this, "__failedVer", -1); - /** Per-instance constructor error — kept here (not on the registry entry) - * so one instance's successful construct can't hide a sibling's failure, - * and a construct can never wipe an eval error `updateJs` recorded on - * `r.logicError`. */ - __publicField(this, "__ctorError", null); - __publicField(this, "logic"); - this.__name = props.__name; - this.state = { __v: 0, __err: null }; - this.__sub = () => { - if (this.state.__err) this.setState({ __err: null }); - this.forceUpdate(); - }; - this.__makeLogic(registry.get(this.__name).Logic, null); - ensureFetched(this.__name); - } - /** Error-boundary hook: a render crash anywhere in this DC's subtree - * (its own template, an x-import'd component, a child DC without its - * own deeper boundary) lands here instead of unmounting the page. */ - static getDerivedStateFromError(e) { - return { __err: e instanceof Error && e.message ? e.message : String(e) }; - } - componentDidCatch(e, info) { - console.error( - "[dc-runtime] render error in <" + this.__name + ">:", - e, - info?.componentStack || "" - ); - } - /** Instantiate the logic class (or the no-op base) and adopt `prevState` - * over its initial state — used both at mount and on hot-swap. */ - __makeLogic(Logic, prevState) { - const L = Logic || StreamableLogic; - try { - this.logic = new L(this.__userProps()); - this.__failedLogic = null; - this.__failedUserProps = null; - this.__ctorError = null; - } catch (e) { - console.error(e); - this.__failedLogic = Logic; - this.__failedUserProps = this.__userProps(); - this.__failedVer = registry.get(this.__name).ver; - this.__ctorError = this.__name + ": " + (e instanceof Error && e.message ? e.message : String(e)); - this.logic = new StreamableLogic( - this.__userProps() - ); - } - this.logic.__host = this; - if (prevState) - this.logic.state = { ...this.logic.state || {}, ...prevState }; - } - /** The props the author's logic + template see — internal __-prefixed - * wiring stripped. */ - __userProps() { - const { __name, __hintSize, __tplId, __hostStyle, ...rest } = this.props; - return rest; - } - __setLogicState(update, cb) { - const prev = this.logic.state; - const patch = typeof update === "function" ? update(prev) : update; - this.logic.state = { ...prev, ...patch }; - this.setState((s) => ({ __v: s.__v + 1 }), cb); - } - /** Swap the logic instance when the registry's Logic class changed - * (streaming completion, hot reload). State carries over; didMount - * re-fires after the swap commits so refs exist. */ - __reconcileLogic() { - const r = registry.get(this.__name); - const Next = r.Logic; - const Cur = this.logic.constructor; - if (Next === Cur || !Next && Cur === StreamableLogic || Next === this.__failedLogic && r.ver === this.__failedVer && shallowEqual(this.__userProps(), this.__failedUserProps)) { - return; - } - if (!this.__needsDidMount) { - try { - this.logic.componentWillUnmount(); - } catch (e) { - console.error(e); - } - } - this.__makeLogic(Next, this.logic.state); - this.__needsDidMount = true; - } - componentDidMount() { - registry.get(this.__name).subs.add(this.__sub); - try { - this.logic.componentDidMount(); - } catch (e) { - console.error(e); - } - } - componentDidUpdate(prevProps) { - this.logic.props = this.__userProps(); - if (this.__needsDidMount) { - if (this.state.__err || !registry.get(this.__name).tpl) return; - this.__needsDidMount = false; - try { - this.logic.componentDidMount(); - } catch (e) { - console.error(e); - } - } else { - try { - this.logic.componentDidUpdate(prevProps); - } catch (e) { - console.error(e); - } - } - } - componentWillUnmount() { - registry.get(this.__name).subs.delete(this.__sub); - if (!this.__needsDidMount) { - try { - this.logic.componentWillUnmount(); - } catch (e) { - console.error(e); - } - } - } - render() { - const r = registry.get(this.__name); - const cls = "sc-host" + (r.htmlStreaming ? " sc-streaming-html" : "") + (r.jsStreaming ? " sc-streaming-js" : ""); - const hintStyle = r.htmlStreaming ? hintToMin(this.props.__hintSize) : void 0; - const hostStyle = this.props.__hostStyle || hintStyle ? { ...hintStyle || {}, ...this.props.__hostStyle || {} } : void 0; - const hostBase = { - className: cls, - style: hostStyle, - "data-sc-name": this.__name, - "data-dc-tpl": this.props.__tplId - }; - const chain = Array.isArray(this.context) ? this.context : []; - if (chain.includes(this.__name)) { - const cycle = [ - ...chain.slice(chain.indexOf(this.__name)), - this.__name - ].join(" \u2192 "); - return h( - "div", - { ...hostBase, className: cls + " sc-has-error" }, - h(Placeholder, { - name: this.__name, - hintSize: this.props.__hintSize, - error: "circular import: " + cycle - }) - ); - } - if (this.state.__err) { - return h( - "div", - { ...hostBase, className: cls + " sc-has-error" }, - h( - "div", - { className: "sc-logic-error", "data-omelette-chrome": "" }, - this.__name + ": " + this.state.__err - ), - h(Placeholder, { - name: this.__name, - hintSize: this.props.__hintSize, - error: this.state.__err - }) - ); - } - this.__reconcileLogic(); - if (!r.tpl) { - return h( - "div", - hostBase, - h(Placeholder, { name: this.__name, hintSize: this.props.__hintSize }) - ); - } - const userProps = this.__userProps(); - this.logic.props = userProps; - let vals = userProps; - let renderErr = r.logicError || this.__ctorError; - try { - vals = { ...userProps, ...this.logic.renderVals() || {} }; - } catch (e) { - console.error(e); - renderErr = this.__name + ".renderVals(): " + (e instanceof Error && e.message ? e.message : String(e)); - } - this.__streamingNow = !!(r.htmlStreaming || r.jsStreaming); - this.__htmlStreamingNow = !!r.htmlStreaming; - return h( - "div", - { ...hostBase, className: cls + (renderErr ? " sc-has-error" : "") }, - renderErr && h( - "div", - { className: "sc-logic-error", "data-omelette-chrome": "" }, - renderErr - ), - h( - AncestorContext.Provider, - { value: [...chain, this.__name] }, - r.tpl(vals, this) - ) - ); - } - } - __publicField(StreamableComponent, "contextType", AncestorContext); - const named = /* @__PURE__ */ new Map(); - function getDC(name) { - const hit = named.get(name); - if (hit) return hit; - function Dispatcher(p) { - const [, setTick] = React.useState(0); - React.useEffect(() => { - const sub = () => setTick((n) => n + 1); - registry.get(name).subs.add(sub); - return () => { - registry.get(name).subs.delete(sub); - }; - }, []); - ensureFetched(name); - return h(StreamableComponent, { ...p, __name: name }); - } - Dispatcher.displayName = name; - named.set(name, Dispatcher); - return Dispatcher; - } - return { - getDC, - StreamableComponent - }; - } - - // src/external.ts - var isCustomElementName = (n) => !n.includes(".") && n.includes("-"); - function isRenderableType(g) { - if (typeof g === "function") return !isElementClass(g); - return typeof g === "object" && g !== null && typeof g.$$typeof === "symbol"; - } - function resolveDottedPath(root, name) { - let cur = root; - for (const seg of name.split(".")) { - if (cur == null) return void 0; - cur = cur[seg]; - } - return cur; - } - var BABEL_URL = "https://unpkg.com/@babel/standalone@7.26.4/babel.min.js"; - var GLOBAL_POLL_INTERVAL_MS = 50; - var GLOBAL_POLL_TIMEOUT_MS = 3e4; - function createExternalModules(onResolved) { - const cache = /* @__PURE__ */ new Map(); - let babelLoading = null; - const reportedMissing = /* @__PURE__ */ new Map(); - const polling = /* @__PURE__ */ new Set(); - function ensureBabel() { - if (window.Babel) return Promise.resolve(); - if (babelLoading) return babelLoading; - babelLoading = new Promise((res, rej) => { - const s = document.createElement("script"); - s.src = BABEL_URL; - s.crossOrigin = "anonymous"; - s.onload = () => res(); - s.onerror = rej; - document.head.appendChild(s); - }); - return babelLoading; - } - function load(kind, url) { - if (cache.has(url)) return; - cache.set(url, null); - console.info("[dc-runtime] x-import: loading", url, "(" + kind + ")"); - const ready = kind === "jsx" ? ensureBabel() : Promise.resolve(); - ready.then(() => fetch(url)).then((r) => { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.text(); - }).then((src) => { - const code = kind === "jsx" ? window.Babel.transform(src, { - filename: url, - presets: ["react", "typescript"] - }).code : src; - const module = { exports: {} }; - const before = new Set(Object.keys(window)); - //! nosemgrep: eval-and-function-constructor - new Function("React", "module", "exports", "require", code)( - getReact(), - module, - module.exports, - () => ({}) - ); - const globals = {}; - for (const k of Object.keys(window)) { - if (!before.has(k) && typeof window[k] === "function") { - globals[k] = window[k]; - } - } - cache.set(url, { mod: module.exports, globals }); - console.info( - "[dc-runtime] x-import: loaded", - url, - "\u2014 exports:", - Object.keys(module.exports), - "window globals:", - Object.keys(globals) - ); - onResolved(); - }).catch((e) => { - cache.set(url, { - mod: {}, - globals: {}, - error: "failed to load: " + (e instanceof Error && e.message ? e.message : String(e)) - }); - console.error( - "[dc-runtime] x-import: FAILED to load", - url, - "(" + kind + ")", - e - ); - onResolved(); - }); - } - function resolve2(url, name) { - const entry = cache.get(url); - if (!entry) return null; - const { mod, globals } = entry; - const C = mod && mod[name] || globals && globals[name] || typeof window !== "undefined" && window[name] || mod && mod.default; - if (typeof C === "function") return C; - const key = url + "\0" + name; - if (!reportedMissing.has(key)) { - reportedMissing.set( - key, - entry.error || 'no export named "' + name + '" (has: ' + Object.keys(mod).join(", ") + ")" - ); - console.error( - "[dc-runtime] x-import: module", - url, - "loaded but has no component named", - JSON.stringify(name), - "\u2014 available exports:", - Object.keys(mod), - "window globals:", - Object.keys(globals), - ". The module must `module.exports = {" + name + "}` or set `window." + name + "`." - ); - } - return null; - } - function waitForGlobal(name) { - if (polling.has(name)) return; - polling.add(name); - const started = Date.now(); - const isCE = isCustomElementName(name); - const tick = () => { - const found = isCE ? customElements.get(name) : isRenderableType(resolveDottedPath(window, name)); - if (found) { - polling.delete(name); - onResolved(); - return; - } - if (Date.now() - started >= GLOBAL_POLL_TIMEOUT_MS) { - console.warn( - "[dc-runtime] x-import: global", - JSON.stringify(name), - "never appeared on window after " + GLOBAL_POLL_TIMEOUT_MS + "ms" - ); - return; - } - setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); - }; - setTimeout(tick, GLOBAL_POLL_INTERVAL_MS); - } - function resolveGlobal(url, name) { - const isCE = isCustomElementName(name); - if (!url) { - if (isCE) { - if (customElements.get(name)) return name; - waitForGlobal(name); - return null; - } - const g2 = resolveDottedPath(window, name); - if (isRenderableType(g2)) return g2; - waitForGlobal(name); - return null; - } - const entry = cache.get(url); - if (!entry) return null; - if (isCE && customElements.get(name)) return name; - const g = entry.globals[name] ?? resolveDottedPath(window, name); - if (isRenderableType(g)) return g; - if (name.includes(".")) return null; - const key = url + "\0global\0" + name; - if (!reportedMissing.has(key)) { - reportedMissing.set(key, null); - if (isCE && !customElements.get(name)) { - console.warn( - "[dc-runtime] x-import:", - url, - "loaded but no custom element", - JSON.stringify(name), - "is registered and window." + name + " is not a function \u2014 rendering <" + name + "> as an unknown element." - ); - } - } - return name; - } - function getError(url, name) { - const entry = cache.get(url); - if (entry?.error) return entry.error; - return reportedMissing.get(url + "\0" + name) || null; - } - return { load, resolve: resolve2, resolveGlobal, getError }; - } - function isElementClass(g) { - try { - return typeof g === "function" && typeof HTMLElement !== "undefined" && g.prototype instanceof HTMLElement; - } catch { - return false; - } - } - - // src/atomics.ts - var ATOMIC_CSS = ( - // layout - ".fx{display:flex}.col{display:flex;flex-direction:column}.grid{display:grid}.ac{align-items:center}.jc{justify-content:center}.jb{justify-content:space-between}.f1{flex:1}.noshrink{flex-shrink:0}.wrap{flex-wrap:wrap}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fs11{font-size:11px}.fs12{font-size:12px}.fs13{font-size:13px}.fs14{font-size:14px}.fs15{font-size:15px}.fs16{font-size:16px}.fs20{font-size:20px}.fs22{font-size:22px}.upper{text-transform:uppercase}.tc{text-align:center}.nowrap{white-space:nowrap}.gap8{gap:8px}.gap10{gap:10px}.gap12{gap:12px}.gap16{gap:16px}.gap24{gap:24px}.m0{margin:0}.mt8{margin-top:8px}.mt12{margin-top:12px}.mt16{margin-top:16px}.mb8{margin-bottom:8px}.mb12{margin-bottom:12px}.mb16{margin-bottom:16px}.posrel{position:relative}.posabs{position:absolute}.round{border-radius:50%}.ohide{overflow:hidden}.bbox{box-sizing:border-box}.pointer{cursor:pointer}.w100{width:100%}.b0{border:none}" - ); - - // src/helmet.ts - var DESIGN_DOC_MODE_RE = /]*\bname\s*=\s*["']design_doc_mode["'][^>]*\b(?:content|value)\s*=\s*["'](\w+)["']/i; - var CANVAS_BG = "#f0eee9"; - function createHelmetManager(doc, isStreaming) { - const mounted = /* @__PURE__ */ new Set(); - const live = /* @__PURE__ */ new Map(); - let designDocMode = null; - let canvasStyleEl = null; - function postDesignMode(mode) { - if (window.parent === window) return; - try { - window.parent.postMessage({ type: "__dc_design_mode", mode }, "*"); - } catch { - } - } - function setDesignDocMode(mode) { - if (mode === designDocMode) return; - designDocMode = mode; - postDesignMode(mode); - if (mode === "canvas") { - doc.documentElement.setAttribute("data-dc-canvas", ""); - canvasStyleEl = doc.createElement("style"); - canvasStyleEl.setAttribute("data-dc-canvas", ""); - canvasStyleEl.textContent = `html,body{background:${CANVAS_BG}}#dc-root>.sc-host{position:relative}`; - doc.head.appendChild(canvasStyleEl); - } else { - doc.documentElement.removeAttribute("data-dc-canvas"); - canvasStyleEl?.remove(); - canvasStyleEl = null; - } - } - window.addEventListener("message", (e) => { - if (!designDocMode || (e.data && e.data.type) !== "__dc_probe") return; - postDesignMode(designDocMode); - }); - function compile(node) { - const raw = [...node.children]; - const helmetClosed = node.nextSibling != null || node.parentNode?.nextSibling != null; - if (node.hasAttribute("data-dc-atomics") && !mounted.has("__dc-atomics")) { - mounted.add("__dc-atomics"); - const el = doc.createElement("style"); - el.id = "__dc-atomics"; - el.textContent = ATOMIC_CSS; - doc.head.appendChild(el); - } - return (_vals, ctx) => { - const name = ctx && ctx.__name || ""; - const streaming = !!(name && isStreaming(name)); - for (let i = 0; i < raw.length; i++) { - const child = raw[i]; - const tag = child.tagName; - const mayBePartial = streaming && !helmetClosed && i === raw.length - 1; - if (tag === "SCRIPT") { - if (mayBePartial) continue; - const key = "SCRIPT|" + (child.getAttribute("src") || child.textContent || ""); - if (mounted.has(key)) continue; - mounted.add(key); - const el = doc.createElement("script"); - for (const { name: an, value } of [...child.attributes]) - el.setAttribute(an, value); - if (child.textContent) el.textContent = child.textContent; - doc.head.appendChild(el); - } else if (tag === "LINK" || tag === "META") { - if (mayBePartial) continue; - const key = tag + "|" + (child.getAttribute("href") || child.getAttribute("src") || child.outerHTML); - if (mounted.has(key)) continue; - mounted.add(key); - doc.head.appendChild(child.cloneNode(true)); - } else { - const key = name + "|" + i; - let el = live.get(key); - if (!el || el.tagName !== tag) { - if (el) el.remove(); - el = doc.createElement(tag.toLowerCase()); - live.set(key, el); - doc.head.appendChild(el); - } - for (const { name: an, value } of [...child.attributes]) { - if (el.getAttribute(an) !== value) el.setAttribute(an, value); - } - if (el.textContent !== child.textContent) - el.textContent = child.textContent; - } - } - return null; - }; - } - return { compile, setDesignDocMode }; - } - - // src/pseudo.ts - function createPseudoSheet(doc) { - let el = null; - const cache = /* @__PURE__ */ new Map(); - let n = 0; - return (pseudo, css) => { - const k = pseudo + "|" + css; - const hit = cache.get(k); - if (hit) return hit; - if (!el) { - el = doc.createElement("style"); - doc.head.appendChild(el); - } - const cls = "scp" + (n++).toString(36); - const sel = pseudo === "before" || pseudo === "after" ? "." + cls + "::" + pseudo : "." + cls + ":" + pseudo; - el.sheet.insertRule(sel + "{" + css + "}", el.sheet.cssRules.length); - cache.set(k, cls); - return cls; - }; - } - - // src/registry.ts - function createRegistry() { - const entries = /* @__PURE__ */ Object.create(null); - function get(name) { - return entries[name] || (entries[name] = { - html: "", - tpl: null, - Logic: null, - jsStreaming: false, - htmlStreaming: false, - ver: 0, - subs: /* @__PURE__ */ new Set(), - fetched: false - }); - } - function bump(name) { - const r = get(name); - r.ver++; - for (const fn of r.subs) fn(); - } - return { - entries, - get, - bump, - bumpAll() { - for (const n in entries) bump(n); - } - }; - } - - // src/runtime.ts - var COMPONENT_DIR = "."; - function createRuntime(doc = document) { - const registry = createRegistry(); - const pseudoClass = createPseudoSheet(doc); - const helmet = createHelmetManager( - doc, - (name) => registry.get(name).htmlStreaming - ); - const external = createExternalModules(() => registry.bumpAll()); - const factory = createComponentFactory(registry, ensureFetched); - const host = { - component: (name) => factory.getDC(name), - placeholder: (props) => h(Placeholder, props), - helmet: (node) => helmet.compile(node), - loadExternal: (kind, url) => external.load(kind, url), - resolveExternal: (url, name) => external.resolve(url, name), - resolveExternalGlobal: (url, name) => external.resolveGlobal(url, name), - resolveExternalError: (url, name) => external.getError(url, name), - pseudoClass - }; - function ensureFetched(name) { - const r = registry.get(name); - if (r.fetched) return; - r.fetched = true; - const url = COMPONENT_DIR + "/" + encodeURIComponent(name) + ".dc.html"; - fetch(url).then((res) => { - if (!res.ok) { - console.error( - "[dc-runtime] sibling fetch for <" + name + "/> failed:", - url, - "returned", - res.status, - "\u2014 the reference renders as an empty placeholder." - ); - return ""; - } - return res.text(); - }).then((t) => { - if (!t) return; - const parsed = parseDcText(t); - if (!parsed) { - console.error( - "[dc-runtime] sibling fetch for <" + name + "/>:", - url, - "has no block \u2014 not a Design Component." - ); - return; - } - if (parsed.props) r.propsMeta = parsed.props; - if (parsed.preview) r.preview = parsed.preview; - if (parsed.template && !r.html) updateHtml(name, parsed.template); - if (parsed.js && !r.Logic) updateJs(name, parsed.js); - }).catch( - (e) => console.error( - "[dc-runtime] sibling fetch for <" + name + "/> threw:", - url, - e - ) - ); - } - let rootName = null; - function updateHtml(name, html) { - const r = registry.get(name); - r.html = html; - if (name === rootName) { - const mode = DESIGN_DOC_MODE_RE.exec(html)?.[1] ?? null; - if (mode || !r.htmlStreaming) helmet.setDesignDocMode(mode); - } - try { - r.tpl = compileTemplate(html, host); - } catch (e) { - console.error("[dc-runtime] template compile FAILED for", name, e); - } - registry.bump(name); - } - function updateJs(name, src) { - const r = registry.get(name); - const seq = r.jsSeq = (r.jsSeq || 0) + 1; - try { - const Cls = evalDcLogic(src); - if (r.jsSeq !== seq) return; - if (typeof Cls !== "function") { - r.logicError = name + ".dc.html: