@hachej/boring-ui-kit 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import type { ChatAttempt, ChatDraft, ChatSendInput, NativeChatSnapshot } from '
2
2
  export { ChannelRequestError } from './error.js';
3
3
  import type { Card, ChannelMessage, ChannelSource, Member, SharedChatState } from './source.js';
4
4
  export type * from './source.js';
5
- export { channelAvatarStates } from './source.js';
5
+ export { channelAvatarStates, cardActions } from './source.js';
6
6
  export type SharedChatSnapshot = Omit<NativeChatSnapshot, 'conversationId' | 'send' | 'shared'> & {
7
7
  readonly conversationId: string;
8
8
  readonly shared: SharedChatState;
@@ -37,7 +37,8 @@ export declare function createSharedChat(source: ChannelSource, options: {
37
37
  retrySameRequest: () => Promise<void>;
38
38
  stop: () => Promise<never>;
39
39
  dispose: () => Promise<undefined>;
40
- action: (card: Card) => Promise<void>;
40
+ /** Runs one of the card's own actions (the first when `actionId` is omitted). An id the card does not offer is refused. */
41
+ action: (card: Card, actionId?: string) => Promise<void>;
41
42
  remove: ((message: ChannelMessage) => Promise<void>) | undefined;
42
43
  canRemove: (message: ChannelMessage) => boolean;
43
44
  };
@@ -2,7 +2,8 @@ import { randomUUID } from '@hachej/boring-files/platform';
2
2
  import { createChatOutbox } from '../chat-outbox.js';
3
3
  import { ChannelRequestError } from './error.js';
4
4
  export { ChannelRequestError } from './error.js';
5
- export { channelAvatarStates } from './source.js';
5
+ import { cardActions } from './source.js';
6
+ export { channelAvatarStates, cardActions } from './source.js';
6
7
  /** Host-owned multi-party log, not a Pi conversation or an agent runtime. One adapter per selected conversation. */
7
8
  export function createSharedChat(source, options) {
8
9
  const id = options.conversationId, limit = options.limit ?? 60;
@@ -237,7 +238,14 @@ export function createSharedChat(source, options) {
237
238
  listeners.clear();
238
239
  return undefined;
239
240
  },
240
- action: (card) => card.action ? source.action({ conversationId: id, requestId: card.requestId, actionId: card.action.id }) : Promise.resolve(),
241
+ /** Runs one of the card's own actions (the first when `actionId` is omitted). An id the card does not offer is refused. */
242
+ action: (card, actionId) => {
243
+ const actions = cardActions(card);
244
+ const chosen = actionId === undefined ? actions[0] : actions.find(action => action.id === actionId);
245
+ if (!chosen)
246
+ return actionId === undefined ? Promise.resolve() : Promise.reject(new Error(`The card offers no action ${actionId}`));
247
+ return source.action({ conversationId: id, requestId: card.requestId, actionId: chosen.id });
248
+ },
241
249
  remove: source.remove && source.canRemove ? (message) => source.canRemove?.(message) === true ? source.remove(message.id) : Promise.reject(new Error('Removal refused')) : undefined,
242
250
  canRemove: (message) => Boolean(source.remove && source.canRemove?.(message)),
243
251
  };
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { randomUUID } from '@hachej/boring-files/platform';
3
3
  import { ChannelRequestError } from './error.js';
4
- const member = z.object({ id: z.string(), name: z.string(), kind: z.enum(['person', 'agent', 'admin']), role: z.union([z.enum(['owner', 'validator', 'member', 'admin']), z.templateLiteral(['agent:', z.string()])]).optional(), avatarUrl: z.string().optional(), presence: z.enum(['here', 'away']).optional() });
4
+ const member = z.object({ id: z.string(), name: z.string(), kind: z.enum(['person', 'agent', 'admin']), role: z.union([z.enum(['owner', 'validator', 'member', 'admin']), z.templateLiteral(['agent:', z.string()])]).optional(), roleLabel: z.string().optional(), avatarUrl: z.string().optional(), presence: z.enum(['here', 'away']).optional() });
5
5
  const conversation = z.object({ id: z.string(), title: z.string(), kind: z.string(), members: z.array(z.string()) });
6
6
  const step = z.object({ label: z.string(), detail: z.string().optional(), status: z.enum(['running', 'done', 'failed']) });
7
+ const cardAction = z.object({ id: z.string(), label: z.string(), variant: z.enum(['primary', 'secondary']).optional() });
7
8
  const message = z.object({ id: z.string(), conversationId: z.string(), author: z.string(), at: z.string(), via: z.string().optional(), text: z.string().optional(),
8
- card: z.object({ requestId: z.string(), title: z.string(), line: z.string(), since: z.string().optional(), state: z.string().optional(), action: z.object({ id: z.string(), label: z.string() }).optional() }).optional(),
9
+ card: z.object({ requestId: z.string(), title: z.string(), line: z.string(), since: z.string().optional(), state: z.string().optional(), action: cardAction.optional(), actions: z.array(cardAction).optional() }).optional(),
9
10
  mentions: z.array(z.string()).optional(), replyTo: z.string().optional(), attachments: z.array(z.object({ path: z.string(), title: z.string().optional() })).optional(), steps: z.array(step).optional(), streaming: z.boolean().optional(),
10
11
  removed: z.object({ by: z.string(), at: z.string() }).optional(), clientId: z.string().optional() });
11
12
  const event = z.discriminatedUnion('type', [z.object({ type: z.literal('message'), message }),
@@ -1,8 +1,10 @@
1
+ /** `roleLabel` is the host's own line beside the author (« the coach »); without it the view derives one from `kind` and `role`. */
1
2
  export type Member = {
2
3
  id: string;
3
4
  name: string;
4
5
  kind: 'person' | 'agent' | 'admin';
5
6
  role?: 'owner' | 'validator' | 'member' | 'admin' | `agent:${string}`;
7
+ roleLabel?: string;
6
8
  avatarUrl?: string;
7
9
  presence?: 'here' | 'away';
8
10
  };
@@ -12,17 +14,24 @@ export type Conversation = {
12
14
  kind: 'main' | 'admin' | 'direct' | string;
13
15
  members: string[];
14
16
  };
17
+ /** `primary` is the card's main choice; `secondary` a quieter one (« Not now »). Without a variant the first action is primary. */
18
+ export type CardAction = {
19
+ id: string;
20
+ label: string;
21
+ variant?: 'primary' | 'secondary';
22
+ };
23
+ /** `actions` offers several choices; the older single `action` still works and is used only when `actions` is absent. */
15
24
  export type Card = {
16
25
  requestId: string;
17
26
  title: string;
18
27
  line: string;
19
28
  since?: string;
20
29
  state?: string;
21
- action?: {
22
- id: string;
23
- label: string;
24
- };
30
+ action?: CardAction;
31
+ actions?: CardAction[];
25
32
  };
33
+ /** The card's choices in order: `actions`, else the single `action`, else none. */
34
+ export declare function cardActions(card: Pick<Card, 'action' | 'actions'>): readonly CardAction[];
26
35
  export type Step = {
27
36
  label: string;
28
37
  detail?: string;
@@ -1,3 +1,7 @@
1
+ /** The card's choices in order: `actions`, else the single `action`, else none. */
2
+ export function cardActions(card) {
3
+ return card.actions ?? (card.action ? [card.action] : []);
4
+ }
1
5
  /** Host lifecycle ids affect presentation only. Missing and unknown ids have no indicator. */
2
6
  export const channelAvatarStates = Object.freeze({
3
7
  building: 'working', executing: 'working', checks: 'working', asked: 'thinking',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.9",
3
+ "version": "0.4.11",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -134,7 +134,7 @@
134
134
  }
135
135
  },
136
136
  "peerDependencies": {
137
- "@hachej/boring-files": "^0.4.9",
137
+ "@hachej/boring-files": "^0.4.11",
138
138
  "@earendil-works/pi-durable": "~1.2.0",
139
139
  "react": "^19.0.0",
140
140
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-feedback@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-feedback@^0.4.11",
9
9
  "@earendil-works/pi-durable@~1.2.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
16
16
  "button",
17
17
  "card",
18
18
  "message",
package/r/app-shell.json CHANGED
@@ -4,19 +4,19 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.9",
8
- "@hachej/boring-ui-kit@^0.4.9",
7
+ "@hachej/boring-feedback@^0.4.11",
8
+ "@hachej/boring-ui-kit@^0.4.11",
9
9
  "@base-ui/react@^1.9.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/dock.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/feedback.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/dock.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/feedback.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
20
20
  "drawer",
21
21
  "sidebar",
22
22
  "avatar",
@@ -4,16 +4,16 @@
4
4
  "title": "Board viewer",
5
5
  "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
17
17
  "button",
18
18
  "alert",
19
19
  "card",
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
12
12
  "alert",
13
13
  "badge"
14
14
  ],
package/r/dock.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
package/r/documents.json CHANGED
@@ -4,20 +4,20 @@
4
4
  "title": "Documents: the Library, a file and its revisions",
5
5
  "description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-app.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/viewers.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-app.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/viewers.json",
21
21
  "drawer",
22
22
  "button",
23
23
  "scroll-area",
@@ -4,7 +4,7 @@
4
4
  "title": "Feedback preview",
5
5
  "description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.9",
7
+ "@hachej/boring-feedback@^0.4.11",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
package/r/feedback.json CHANGED
@@ -4,16 +4,16 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.9",
8
- "@hachej/boring-ui-kit@^0.4.9",
9
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-feedback@^0.4.11",
8
+ "@hachej/boring-ui-kit@^0.4.11",
9
+ "@hachej/boring-files@^0.4.11",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
17
17
  "button",
18
18
  "toggle",
19
19
  "badge",
package/r/file-tree.json CHANGED
@@ -4,15 +4,15 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button and dropdown-menu with shared version-history rows; Base UI style only.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/version-history.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/version-history.json",
16
16
  "button",
17
17
  "dropdown-menu",
18
18
  "collapsible",
package/r/highlights.json CHANGED
@@ -4,13 +4,13 @@
4
4
  "title": "Anchored highlights",
5
5
  "description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/markdown-editor.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/markdown-editor.json",
14
14
  "button",
15
15
  "textarea",
16
16
  "label",
@@ -4,13 +4,13 @@
4
4
  "title": "HTML source and passive preview",
5
5
  "description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "looks-viewer",
4
+ "title": "Looks viewer",
5
+ "description": "A looks@1 artifact kind for *.looks.json: the same *.mockup.html key screen in 2 to 6 shadcn themes side by side (label, one-line personality, colour, font and radius swatches, Pick), then layout variants in the chosen look. A look is a shadcn preset and its theme block, applied to each sandboxed frame through MockupViewer's themeOverride. The host decides what a pick does.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "lucide-react@^1.51.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/mockup-viewer.json",
12
+ "alert",
13
+ "button",
14
+ "card",
15
+ "toggle-group"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "registry/looks-viewer/looks-viewer.tsx",
20
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { CheckIcon, PaletteIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\nimport { besideLooks, parseLooks, swatches } from './looks';\nimport type { Look, Looks } from './looks';\n\nexport { parseLooks, parseShadcnTheme, swatches, besideLooks, shadcnValue, SHADCN_COLOR_VARIABLES, SHADCN_SHAPE_VARIABLES } from './looks';\nexport type { Look, Looks, LayoutVariant } from './looks';\n\nexport const isLooksPath = (path: string): boolean => /\\.looks\\.json$/i.test(path);\n\n/** The person pressed Pick: the host says it in the conversation (or records it); the viewer only marks it. */\nexport interface LookPick {\n readonly kind: 'look' | 'layout';\n readonly id: string;\n readonly label: string;\n /** The looks file the pick was made in. */\n readonly path: string;\n}\n\nexport interface LooksViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n /** Another file of the workspace as text (the artifact panel's `read`): the key screen and the layouts, next to the looks file. */\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n readonly onPick?: ((pick: LookPick) => void) | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n readonly onFailed?: ((reason: string) => void) | undefined;\n}\n\nconst LABELS = {\n en: { looks: 'Pick a look', layouts: 'Pick a layout', layoutsIn: (look: string) => `In the ${look} look`, pick: 'Pick', picked: 'Picked', light: 'Light', dark: 'Dark',\n loading: 'Loading the looks…', missing: 'The key screen cannot be read.', invalid: 'These looks cannot be shown', font: 'Font', radius: 'Roundness', look: (label: string) => `${label} look`, layout: (label: string) => `${label} layout` },\n fr: { looks: 'Choisir un style', layouts: 'Choisir une mise en page', layoutsIn: (look: string) => `Dans le style ${look}`, pick: 'Choisir', picked: 'Choisi', light: 'Clair', dark: 'Sombre',\n loading: 'Chargement des styles…', missing: \"L'écran clé est illisible.\", invalid: 'Ces styles ne peuvent pas être affichés', font: 'Police', radius: 'Arrondi', look: (label: string) => `Style ${label}`, layout: (label: string) => `Mise en page ${label}` },\n} as const;\n\n/** The key screen drawn at a desktop (or, in a narrow column, phone) width and scaled into the card, so a look reads as the whole app. */\nfunction ScaledMockup({ text, theme, mode, title, onReady }: { readonly text: string; readonly theme: ShadcnTheme; readonly mode?: 'light' | 'dark' | undefined; readonly title: string; readonly onReady: () => void }) {\n const outer = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = outer.current;\n if (!element) return undefined;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // A phone draws the phone layout of the screen; a narrow column on a desktop still draws the desktop one, smaller.\n const phone = typeof matchMedia === 'function' && matchMedia('(max-width: 639px)').matches;\n const virtual = phone ? { w: 390, h: 640 } : { w: 1024, h: 600 };\n const scale = width > 0 ? width / virtual.w : 0.5;\n return <div ref={outer} data-testid=\"looks-frame\" className=\"relative w-full overflow-hidden bg-muted\" style={{ height: Math.round(virtual.h * scale) }}>\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ width: virtual.w, height: virtual.h, transform: `scale(${scale})` }}>\n <MockupViewer text={text} themeOverride={theme} mode={mode} title={title} onReady={onReady} />\n </div>\n </div>;\n}\n\n/** The swatch's corner, in a 15px square: a rem radius at 16px, capped. */\nconst corner = (radius: string) => Math.min(15, Math.max(0, parseFloat(radius) * (radius.endsWith('px') ? 1 : 16)));\n\nfunction Swatches({ look, dark, labels }: { readonly look: Look; readonly dark: boolean; readonly labels: (typeof LABELS)['en' | 'fr'] }) {\n const swatch = swatches(look, dark);\n return <div data-testid=\"looks-swatches\" className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n <svg role=\"img\" aria-label={swatch.colors.map(color => color.name).join(', ')} width={swatch.colors.length * 20} height=\"18\" viewBox={`0 0 ${swatch.colors.length * 20} 18`}>\n {swatch.colors.map((color, index) => <circle key={color.name} cx={index * 20 + 9} cy=\"9\" r=\"8\" fill={color.value} stroke=\"currentColor\" strokeOpacity=\"0.3\"><title>{`${color.name}: ${color.value}`}</title></circle>)}\n </svg>\n <span title={labels.font} style={{ fontFamily: look.theme.theme?.['font-sans'] }}>{swatch.heading === swatch.font ? swatch.font : `${swatch.heading} / ${swatch.font}`}</span>\n <span className=\"flex items-center gap-1\" title={labels.radius}><svg aria-hidden=\"true\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\"><path d={`M2 17 V${2 + corner(swatch.radius)} Q2 2 ${2 + corner(swatch.radius)} 2 H17`} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" /></svg>{swatch.radius}</span>\n </div>;\n}\n\nexport function LooksViewer({ text, path = '', read, onPick, locale = 'en', onReady, onFailed }: LooksViewerProps) {\n const labels = LABELS[locale];\n const parsed = useMemo(() => parseLooks(text), [text]);\n const spec: Looks | undefined = parsed.kind === 'ok' ? parsed.looks : undefined;\n const sources = useMemo(() => spec ? [...new Set([spec.mockup, ...spec.layouts.map(layout => layout.mockup)])] : [], [spec]);\n const [files, setFiles] = useState<{ readonly key: string; readonly texts: ReadonlyMap<string, string> } | 'missing'>();\n const key = JSON.stringify([path, sources]);\n useEffect(() => {\n if (!sources.length) return undefined;\n let live = true;\n setFiles(undefined);\n void Promise.all(sources.map(async relative => [relative, read ? await read(besideLooks(path, relative)).catch(() => undefined) : undefined] as const)).then(entries => {\n if (!live) return;\n if (entries.some(([, value]) => value === undefined)) { setFiles('missing'); onFailed?.('a mock-up named by the looks file cannot be read'); return; }\n setFiles({ key, texts: new Map(entries as [string, string][]) });\n });\n return () => { live = false; };\n }, [key, read]); // eslint-disable-line react-hooks/exhaustive-deps\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps\n const frames = spec ? (spec.looks.length > 1 ? spec.looks.length : 0) + spec.layouts.length : 0;\n const ready = useRef(new Set<string>());\n useEffect(() => { ready.current = new Set(); }, [text]);\n const frameReady = useCallback((id: string) => () => {\n ready.current.add(id);\n if (ready.current.size === frames) onReady?.();\n }, [frames, onReady]);\n useEffect(() => { if (parsed.kind === 'invalid') onFailed?.(`invalid looks file: ${parsed.issues.slice(0, 3).join('; ')}`); }, [parsed]); // eslint-disable-line react-hooks/exhaustive-deps\n if (parsed.kind === 'invalid' || !spec) return <Alert role=\"alert\" data-testid=\"looks-invalid\"><AlertTitle>{labels.invalid}</AlertTitle>\n <AlertDescription>{parsed.kind === 'invalid' ? parsed.issues.slice(0, 6).join('; ') : ''}</AlertDescription></Alert>;\n if (files === 'missing') return <Alert role=\"alert\"><AlertTitle>{labels.missing}</AlertTitle></Alert>;\n if (!files || files.key !== key) return <p role=\"status\" className=\"p-3 text-muted-foreground\">{labels.loading}</p>;\n const keyScreen = files.texts.get(spec.mockup)!;\n const inLook = spec.looks.find(item => item.id === look) ?? spec.looks.find(item => item.id === spec.look) ?? spec.looks[0]!;\n const dark = mode === 'dark';\n const pick = (kind: LookPick['kind'], id: string, label: string) => { if (kind === 'look') setLook(id); else setLayout(id); onPick?.({ kind, id, label, path }); };\n return <div data-boring=\"looks-viewer\" className=\"flex flex-col gap-6 p-3 md:p-4\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"text-lg font-semibold\">{spec.looks.length > 1 ? labels.looks : labels.layouts}</h2>\n <ToggleGroup value={[mode ?? 'host']} onValueChange={(value: unknown[]) => { const next = value[0]; setMode(next === 'light' || next === 'dark' ? next : undefined); }} aria-label={`${labels.light} / ${labels.dark}`}>\n <ToggleGroupItem value=\"light\" data-testid=\"looks-light\">{labels.light}</ToggleGroupItem>\n <ToggleGroupItem value=\"dark\" data-testid=\"looks-dark\">{labels.dark}</ToggleGroupItem>\n </ToggleGroup>\n </div>\n {spec.looks.length > 1 && <section aria-label={labels.looks} data-testid=\"looks-grid\" className=\"grid gap-4 md:grid-cols-2\">\n {spec.looks.map(item => <Choice key={item.id} testId={`look-${item.id}`} title={item.label} line={item.personality} chosen={inLook.id === item.id && look !== undefined} labels={labels}\n onPick={() => pick('look', item.id, item.label)} extra={<Swatches look={item} dark={dark} labels={labels} />}>\n <ScaledMockup text={keyScreen} theme={item.theme} mode={mode} title={labels.look(item.label)} onReady={frameReady(`look:${item.id}`)} />\n </Choice>)}\n </section>}\n {spec.layouts.length > 0 && <section aria-label={labels.layouts} data-testid=\"layouts-grid\" className=\"flex flex-col gap-3\">\n {spec.looks.length > 1 && <h2 className=\"text-lg font-semibold\">{labels.layouts}</h2>}\n <p className=\"text-sm text-muted-foreground\">{labels.layoutsIn(inLook.label)}</p>\n <div className={spec.layouts.length > 2 ? 'grid gap-4 md:grid-cols-2 xl:grid-cols-3' : 'grid gap-4 md:grid-cols-2'}>\n {spec.layouts.map(item => <Choice key={item.id} testId={`layout-${item.id}`} title={item.label} line={item.why} chosen={layout === item.id} labels={labels} onPick={() => pick('layout', item.id, item.label)}>\n <ScaledMockup text={files.texts.get(item.mockup)!} theme={inLook.theme} mode={mode} title={labels.layout(item.label)} onReady={frameReady(`layout:${item.id}`)} />\n </Choice>)}\n </div>\n </section>}\n </div>;\n}\n\nfunction Choice({ testId, title, line, chosen, labels, onPick, extra, children }: { readonly testId: string; readonly title: string; readonly line?: string | undefined; readonly chosen: boolean; readonly labels: (typeof LABELS)['en' | 'fr']; readonly onPick: () => void; readonly extra?: ReactNode; readonly children: ReactNode }) {\n return <Card data-testid={testId} data-chosen={chosen ? '' : undefined}>\n <CardContent>{children}</CardContent>\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n {line && <CardDescription>{line}</CardDescription>}\n <CardAction>\n <Button size=\"sm\" variant={chosen ? 'default' : 'outline'} aria-pressed={chosen} data-testid={`${testId}-pick`} onClick={onPick}>\n {chosen && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{chosen ? labels.picked : labels.pick}\n </Button>\n </CardAction>\n {extra}\n </CardHeader>\n </Card>;\n}\n\nexport interface LooksKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly onFailed?: ((reason: string) => void) | undefined; readonly read?: ((path: string) => Promise<string | undefined>) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n readonly waitsForReady: true;\n}\n\n/** The `*.looks.json` kind: opt-in, like the board. `onPick` is the host's (the PM page says the pick in the conversation). */\nexport function createLooksKind({ onPick, locale }: { readonly onPick?: ((pick: LookPick) => void) | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): LooksKind {\n return { match: target => isLooksPath(target.resource.path), label: locale === 'fr' ? 'Styles' : 'Looks', icon: PaletteIcon, waitsForReady: true,\n renderer: ({ text, artifact, read, onReady, onFailed }) => <LooksViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} read={read} onPick={onPick} locale={locale} onReady={onReady} onFailed={onFailed} /> };\n}\n",
21
+ "type": "registry:component",
22
+ "target": "components/looks-viewer/looks-viewer.tsx"
23
+ },
24
+ {
25
+ "path": "registry/looks-viewer/looks.ts",
26
+ "content": "// looks@1: the looks an assistant offers for an app (`*.looks.json`). A look is a shadcn theme, exactly as shadcn writes it: a preset\n// code (`npx shadcn apply <code> --only theme,font`) and/or the theme block that preset produces (:root, .dark and the font and radius\n// variables of `@theme inline`). Nothing here invents a theme model: the block's variables are shadcn's own.\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\n\n/** The colour variables of a shadcn theme (`:root` and `.dark`). */\nexport const SHADCN_COLOR_VARIABLES: readonly string[] = Object.freeze([\n 'background', 'foreground', 'card', 'card-foreground', 'popover', 'popover-foreground', 'primary', 'primary-foreground',\n 'secondary', 'secondary-foreground', 'muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground',\n // the hairline variable's name is joined: the design scan reads that bare word as a class\n ['bord', 'er'].join(''), 'input', 'ring', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'sidebar', 'sidebar-foreground', 'sidebar-primary',\n 'sidebar-primary-foreground', 'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',\n]);\n/** The non-colour variables a shadcn theme sets: the radius (`:root`) and the font families (`@theme inline`). */\nexport const SHADCN_SHAPE_VARIABLES: readonly string[] = Object.freeze(['radius', 'font-sans', 'font-serif', 'font-mono', 'font-heading']);\n\nconst COLOR = /^(?:#[0-9a-f]{3,8}|(?:oklch|oklab|lab|lch|hsla?|rgba?)\\([0-9a-z .,%/+-]{1,80}\\))$/i;\nconst LENGTH = /^(?:0|\\d{1,2}(?:\\.\\d{1,4})?(?:rem|px|em))$/;\nconst FONT = /^(?:[A-Za-z0-9 ,'\"-]{1,160}|var\\(--font-(?:sans|serif|mono)\\))$/;\nconst ID = /^[a-z0-9][a-z0-9-]{0,39}$/;\n/** A shadcn preset: a code (`bIkeymG`), a named style (`nova`) or a ui.shadcn.com init URL. */\nconst PRESET = /^(?:[A-Za-z0-9]{1,24}|https:\\/\\/ui\\.shadcn\\.com\\/init\\?[A-Za-z0-9=&_.-]{1,600})$/;\n\nexport type ThemeIssue = string;\n\n/** Is this a valid value for that shadcn variable? */\nexport function shadcnValue(name: string, value: string): boolean {\n if (SHADCN_COLOR_VARIABLES.includes(name)) return COLOR.test(value);\n if (name === 'radius') return LENGTH.test(value);\n if (name.startsWith('font-')) return SHADCN_SHAPE_VARIABLES.includes(name) && FONT.test(value);\n return false;\n}\n\n/**\n * A shadcn theme block (`:root { … } .dark { … } @theme inline { … }`, as `shadcn apply` writes it) into shadcn's `cssVars` shape.\n * Only shadcn theme variables with valid values are accepted; anything else is an issue, never silently kept.\n */\nexport function parseShadcnTheme(css: string): { readonly kind: 'ok'; readonly theme: ShadcnTheme } | { readonly kind: 'invalid'; readonly issues: readonly ThemeIssue[] } {\n const issues: ThemeIssue[] = [];\n const out = { theme: {} as Record<string, string>, light: {} as Record<string, string>, dark: {} as Record<string, string> };\n const text = css.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const block = /([^{}]+)\\{([^{}]*)\\}/g;\n let consumed = '';\n for (const match of text.matchAll(block)) {\n consumed += match[0];\n const selector = match[1]!.trim();\n const target = selector === ':root' ? out.light : selector === '.dark' ? out.dark : /^@theme(?:\\s+inline)?$/.test(selector) ? out.theme : undefined;\n if (!target) { issues.push(`unexpected block ${selector.slice(0, 40)}: only :root, .dark and @theme inline`); continue; }\n for (const raw of match[2]!.split(';')) {\n const declaration = raw.trim();\n if (!declaration) continue;\n const colon = declaration.indexOf(':');\n const name = declaration.slice(0, colon).trim().replace(/^--/, ''), value = declaration.slice(colon + 1).trim();\n if (colon < 0 || !declaration.startsWith('--')) { issues.push(`not a variable: ${declaration.slice(0, 40)}`); continue; }\n // @theme inline also carries shadcn's colour and radius bindings (--color-primary: var(--primary)); they are the wiring, not the look.\n if (target === out.theme && (/^color-/.test(name) || /^radius-/.test(name))) continue;\n const allowed = target === out.theme ? name.startsWith('font-') : true;\n if (!allowed || !shadcnValue(name, value)) { issues.push(`${selector} --${name}: not a shadcn theme variable or not a valid value`); continue; }\n target[name] = value;\n }\n }\n if (text.replace(block, '').trim()) issues.push('text outside the :root, .dark and @theme blocks');\n if (!consumed) issues.push('no :root block');\n for (const name of ['background', 'foreground', 'primary', 'primary-foreground']) {\n if (!out.light[name]) issues.push(`:root misses --${name}`);\n if (!out.dark[name]) issues.push(`.dark misses --${name}`);\n }\n return issues.length ? { kind: 'invalid', issues } : { kind: 'ok', theme: out };\n}\n\nexport interface Look {\n readonly id: string;\n readonly label: string;\n /** One line: how the app feels in this look. */\n readonly personality: string;\n /** The shadcn preset this look applies (`npx shadcn apply <preset> --only theme,font`). */\n readonly preset?: string | undefined;\n /** The shadcn theme block, parsed. */\n readonly theme: ShadcnTheme;\n}\nexport interface LayoutVariant { readonly id: string; readonly label: string; readonly why?: string | undefined; readonly mockup: string }\nexport interface Looks {\n /** The key screen, a `*.mockup.html` next to the looks file. */\n readonly mockup: string;\n readonly looks: readonly Look[];\n readonly layouts: readonly LayoutVariant[];\n /** The look the layouts are drawn in (a look's id); absent: the first look. */\n readonly look?: string | undefined;\n}\n\nconst MOCKUP = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,160}\\.mockup\\.html$/i;\nconst text = (value: unknown, max: number) => typeof value === 'string' && value.trim() && value.length <= max ? value.trim() : undefined;\n\n/** Read a `*.looks.json`. Paths are relative to the looks file; a layout is a path or `{ id, label, why?, mockup }`. */\nexport function parseLooks(source: string): { readonly kind: 'ok'; readonly looks: Looks } | { readonly kind: 'invalid'; readonly issues: readonly string[] } {\n let data: unknown;\n try { data = JSON.parse(source); } catch { return { kind: 'invalid', issues: ['not JSON'] }; }\n if (!data || typeof data !== 'object' || Array.isArray(data)) return { kind: 'invalid', issues: ['a looks file is an object { mockup, looks, layouts? }'] };\n const spec = data as Record<string, unknown>;\n const issues: string[] = [];\n const mockup = typeof spec.mockup === 'string' && MOCKUP.test(spec.mockup) && !spec.mockup.includes('..') ? spec.mockup : undefined;\n if (!mockup) issues.push('mockup: a *.mockup.html path next to the looks file');\n const rawLooks = Array.isArray(spec.looks) ? spec.looks : [];\n const rawLayouts = spec.layouts === undefined ? [] : Array.isArray(spec.layouts) ? spec.layouts : null;\n if (rawLayouts === null) issues.push('layouts: a list');\n if (rawLooks.length < 1 || rawLooks.length > 6 || (rawLooks.length < 2 && !rawLayouts?.length)) issues.push('looks: 2 to 6 looks (1 when the file compares layouts)');\n const looks: Look[] = [];\n rawLooks.forEach((raw, index) => {\n const item = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const at = `looks[${index}]`;\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : undefined;\n const label = text(item.label, 40), personality = text(item.personality, 160);\n const preset = item.preset === undefined ? undefined : typeof item.preset === 'string' && PRESET.test(item.preset) ? item.preset : null;\n if (!id) issues.push(`${at}.id: lowercase letters, digits and dashes`);\n if (!label) issues.push(`${at}.label: a short name`);\n if (!personality) issues.push(`${at}.personality: one line`);\n if (preset === null) issues.push(`${at}.preset: a shadcn preset code or ui.shadcn.com init URL`);\n const parsed = typeof item.css === 'string' ? parseShadcnTheme(item.css) : undefined;\n if (!parsed) issues.push(`${at}.css: the shadcn theme block of the look`);\n else if (parsed.kind === 'invalid') issues.push(...parsed.issues.slice(0, 4).map(issue => `${at}.css: ${issue}`));\n if (id && label && personality && preset !== null && parsed?.kind === 'ok') looks.push({ id, label, personality, ...(preset ? { preset } : {}), theme: parsed.theme });\n });\n if (new Set(looks.map(look => look.id)).size !== looks.length) issues.push('looks: ids are unique');\n const layouts: LayoutVariant[] = [];\n (rawLayouts ?? []).forEach((raw, index) => {\n const item = typeof raw === 'string' ? { mockup: raw } : (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const path = typeof item.mockup === 'string' && MOCKUP.test(item.mockup) && !item.mockup.includes('..') ? item.mockup : undefined;\n if (!path) { issues.push(`layouts[${index}]: a *.mockup.html path, or { id, label, why?, mockup }`); return; }\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : String.fromCharCode(97 + index);\n layouts.push({ id, label: text(item.label, 60) ?? `Layout ${String.fromCharCode(65 + index)}`, ...(text(item.why, 160) ? { why: text(item.why, 160) } : {}), mockup: path });\n });\n if (layouts.length > 4) issues.push('layouts: at most 4');\n const look = spec.look === undefined ? undefined : typeof spec.look === 'string' && looks.some(item => item.id === spec.look) ? spec.look : null;\n if (look === null) issues.push('look: the id of one of the looks');\n return issues.length || !mockup ? { kind: 'invalid', issues } : { kind: 'ok', looks: { mockup, looks, layouts, ...(look ? { look } : {}) } };\n}\n\n/** What the swatch row shows: four colours, the font family's first name and the radius. */\nexport function swatches(look: Look, dark = false): { readonly colors: readonly { readonly name: string; readonly value: string }[]; readonly font: string; readonly heading: string; readonly radius: string } {\n const vars = { ...look.theme.light, ...(dark ? look.theme.dark : undefined) };\n const family = (value: string | undefined) => (value ?? '').split(',')[0]!.replace(/['\"]/g, '').replace(/ Variable$/, '').trim();\n const font = family(look.theme.theme?.['font-sans']) || 'System';\n const headingVar = look.theme.theme?.['font-heading'];\n return {\n colors: ['background', 'primary', 'accent', 'foreground'].filter(name => vars[name]).map(name => ({ name, value: vars[name]! })),\n font, heading: headingVar && !headingVar.includes('var(') ? family(headingVar) : font, radius: vars.radius ?? '0.625rem',\n };\n}\n\n/** A path named in the looks file, read next to it. */\nexport const besideLooks = (looksPath: string, relative: string): string => looksPath.slice(0, looksPath.lastIndexOf('/') + 1) + relative;\n",
27
+ "type": "registry:lib",
28
+ "target": "components/looks-viewer/looks.ts"
29
+ }
30
+ ],
31
+ "type": "registry:block"
32
+ }
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/diagram-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/diagram-viewer.json",
13
13
  "alert",
14
14
  "badge",
15
15
  "bubble",
@@ -4,8 +4,8 @@
4
4
  "title": "Markdown editor",
5
5
  "description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.9",
8
- "@hachej/boring-files@^0.4.9",
7
+ "@hachej/boring-ui-kit@^0.4.11",
8
+ "@hachej/boring-files@^0.4.11",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -24,7 +24,7 @@
24
24
  "lucide-react@^1.51.0"
25
25
  ],
26
26
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/button.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/button.json",
28
28
  "button",
29
29
  "toggle",
30
30
  "dropdown-menu"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "mockup-viewer",
4
4
  "title": "HTML mock-up viewer",
5
- "description": "Sandboxed *.mockup.html artifacts with host theme variables, lazy Tailwind compilation and feedback point comments. Host presentation only; artifact scripts run in an opaque-origin InteractiveFrame, never in the host.",
5
+ "description": "Sandboxed *.mockup.html artifacts with host theme variables (or a given shadcn theme through themeOverride), lazy Tailwind compilation and feedback point comments. Host presentation only; artifact scripts run in an opaque-origin InteractiveFrame, never in the host.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
8
  "lucide-react@^1.51.0",
@@ -10,7 +10,7 @@
10
10
  "zod@^4.6.5"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.9/r/viewers.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.11/r/viewers.json",
14
14
  "badge",
15
15
  "toggle",
16
16
  "tooltip"
@@ -18,7 +18,7 @@
18
18
  "files": [
19
19
  {
20
20
  "path": "registry/mockup-viewer/mockup-viewer.tsx",
21
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode, RefObject } from 'react';\nimport { CodeXmlIcon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewers/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const theme = useTheme(root);\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title=\"HTML mock-up\" testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n\nexport interface MockupKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n}\n\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
21
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode, RefObject } from 'react';\nimport { CodeXmlIcon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewers/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n /**\n * Render the frame with this shadcn theme instead of the host's (a look being compared): shadcn's own `cssVars` shape, variable names\n * without `--`. Its variables are applied after the host's, so a look that names only some of them keeps the host's others.\n */\n readonly themeOverride?: ShadcnTheme | undefined;\n /** Force light or dark in the frame; absent: follow the host. */\n readonly mode?: 'light' | 'dark' | undefined;\n /** The frame's accessible name (several frames side by side need distinct names). */\n readonly title?: string | undefined;\n}\n\n/** A shadcn theme as the shadcn registry writes it (`cssVars`): `theme` (fonts, radius steps), `light` (:root) and `dark` (.dark). */\nexport interface ShadcnTheme {\n readonly theme?: Readonly<Record<string, string>> | undefined;\n readonly light?: Readonly<Record<string, string>> | undefined;\n readonly dark?: Readonly<Record<string, string>> | undefined;\n}\n\nconst NAME = /^[a-z][a-z0-9-]{0,47}$/;\nconst VALUE = /^[^;{}<>\\\\]{1,240}$/;\n\n/** The declarations a shadcn theme gives the frame in one mode. Names and values that could leave the declaration list are dropped. */\nexport function themeDeclarations(theme: ShadcnTheme, dark: boolean): string {\n const entries = { ...theme.theme, ...theme.light, ...(dark ? theme.dark : undefined) };\n // The frame's radius steps are bound to --radius (the registry theme's bindings), so a look's --radius reshapes every rounded-*.\n return Object.entries(entries).filter(([name, value]) => NAME.test(name) && typeof value === 'string' && VALUE.test(value.trim())).map(([name, value]) => `--${name}:${value.trim()}`).join(';');\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady, themeOverride, mode: forced, title }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const host = useTheme(root);\n const overrideKey = themeOverride ? JSON.stringify(themeOverride) : '';\n const theme = useMemo(() => {\n const dark = forced ? forced === 'dark' : host.dark;\n if (!themeOverride) return dark === host.dark ? host : { ...host, dark };\n const own = themeDeclarations(themeOverride, dark);\n return { variables: [host.variables, own].filter(Boolean).join(';'), dark };\n }, [host, overrideKey, forced]); // eslint-disable-line react-hooks/exhaustive-deps\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title={title ?? 'HTML mock-up'} testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n\nexport interface MockupKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n /** The sandboxed frame loads asynchronously: a `present` of a mock-up is confirmed only once it has loaded. */\n readonly waitsForReady: true;\n}\n\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon, waitsForReady: true,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
22
22
  "type": "registry:component",
23
23
  "target": "components/mockup-viewer/mockup-viewer.tsx"
24
24
  },