@slopus/happy-terminal 0.3.0-beta.13
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.
- package/LICENSE +21 -0
- package/LICENSE-CODEX +203 -0
- package/LICENSE-GROK-BUILD +204 -0
- package/LICENSE-HAPPY +21 -0
- package/README.md +60 -0
- package/THIRD-PARTY-NOTICES.md +34 -0
- package/dist/HappyTerminalUserError.d.ts +12 -0
- package/dist/app/AppTranscriptEntry.d.ts +33 -0
- package/dist/app/AppendOnlyStreamingRender.d.ts +12 -0
- package/dist/app/CodexFileDiff.d.ts +20 -0
- package/dist/app/CodexMcpToolCall.d.ts +25 -0
- package/dist/app/CodingAssistantAgentBackend.d.ts +81 -0
- package/dist/app/CodingAssistantApp.d.ts +128 -0
- package/dist/app/CompletedTurn.d.ts +11 -0
- package/dist/app/FileMentionAutocomplete.d.ts +30 -0
- package/dist/app/HappyTerminalProcessTerminal.d.ts +7 -0
- package/dist/app/NoticeChild.d.ts +4 -0
- package/dist/app/SecretMenuController.d.ts +31 -0
- package/dist/app/SelectionList.d.ts +24 -0
- package/dist/app/SessionDraftSync.d.ts +51 -0
- package/dist/app/StartupStatusApp.d.ts +34 -0
- package/dist/app/StartupStatusCardModel.d.ts +22 -0
- package/dist/app/TemporaryFullscreenController.d.ts +9 -0
- package/dist/app/TerminalColorLevel.d.ts +1 -0
- package/dist/app/TerminalOutputTrace.d.ts +12 -0
- package/dist/app/TerminalTheme.d.ts +11 -0
- package/dist/app/TranscriptEntryRenderCache.d.ts +10 -0
- package/dist/app/applyWorkflowRunUpdate.d.ts +2 -0
- package/dist/app/blendComposerBackground.d.ts +2 -0
- package/dist/app/boundedJsonStringify.d.ts +1 -0
- package/dist/app/calculateCacheHitPercent.d.ts +2 -0
- package/dist/app/codexDiffAnsi.d.ts +17 -0
- package/dist/app/compactCompletedTurnEntries.d.ts +2 -0
- package/dist/app/containsMarkdownTable.d.ts +1 -0
- package/dist/app/createAgentMarkdownTheme.d.ts +3 -0
- package/dist/app/createBackgroundTerminalViewer.d.ts +16 -0
- package/dist/app/createCompletedTurn.d.ts +6 -0
- package/dist/app/createEditorTheme.d.ts +3 -0
- package/dist/app/createFileMentionAutocompleteItems.d.ts +3 -0
- package/dist/app/createSecretInputPanel.d.ts +12 -0
- package/dist/app/createSelectionPanel.d.ts +14 -0
- package/dist/app/createSerialTaskQueue.d.ts +1 -0
- package/dist/app/createSessionPicker.d.ts +17 -0
- package/dist/app/createSlashCommands.d.ts +7 -0
- package/dist/app/createStopOnceHandler.d.ts +1 -0
- package/dist/app/createSubagentMonitor.d.ts +17 -0
- package/dist/app/createTerminalInputBurstHandler.d.ts +5 -0
- package/dist/app/createWorkflowMonitor.d.ts +15 -0
- package/dist/app/defaultTerminalTheme.d.ts +1 -0
- package/dist/app/describeModelChoice.d.ts +4 -0
- package/dist/app/describeProviderSignIn.d.ts +2 -0
- package/dist/app/describeReasoningLevel.d.ts +4 -0
- package/dist/app/detectCodexDiffLanguage.d.ts +1 -0
- package/dist/app/encodeModelChoice.d.ts +1 -0
- package/dist/app/endsAfterOpeningCodeFence.d.ts +1 -0
- package/dist/app/findFileMentionContext.d.ts +9 -0
- package/dist/app/findFileMentionPrefix.d.ts +5 -0
- package/dist/app/formatActivityElapsedTime.d.ts +1 -0
- package/dist/app/formatCodexMcpToolResult.d.ts +3 -0
- package/dist/app/formatCompactTokens.d.ts +1 -0
- package/dist/app/formatFileMention.d.ts +1 -0
- package/dist/app/formatProviderError.d.ts +6 -0
- package/dist/app/formatRelativeTime.d.ts +2 -0
- package/dist/app/formatResetDuration.d.ts +1 -0
- package/dist/app/formatSessionPickerEntry.d.ts +14 -0
- package/dist/app/formatSessionTokenStatus.d.ts +6 -0
- package/dist/app/formatSessionUsageSummary.d.ts +3 -0
- package/dist/app/formatStartupStatusUsageRemaining.d.ts +2 -0
- package/dist/app/formatStartupStatusUsageRows.d.ts +2 -0
- package/dist/app/formatSubagentToolCall.d.ts +10 -0
- package/dist/app/formatToolPermissionNotice.d.ts +3 -0
- package/dist/app/formatToolResultForDisplay.d.ts +2 -0
- package/dist/app/formatTurnUsageSummary.d.ts +2 -0
- package/dist/app/formatWorkUsageSummary.d.ts +14 -0
- package/dist/app/highlightAgentCode.d.ts +1 -0
- package/dist/app/highlightCodexDiffLine.d.ts +2 -0
- package/dist/app/highlightShellCommand.d.ts +1 -0
- package/dist/app/humanizeGoalStatus.d.ts +2 -0
- package/dist/app/humanizeMcpName.d.ts +1 -0
- package/dist/app/humanizePermissionMode.d.ts +2 -0
- package/dist/app/humanizePermissionReviewLevel.d.ts +2 -0
- package/dist/app/humanizeProviderId.d.ts +1 -0
- package/dist/app/humanizeReasoningLevel.d.ts +1 -0
- package/dist/app/humanizeSubagentStatus.d.ts +2 -0
- package/dist/app/humanizeToolName.d.ts +1 -0
- package/dist/app/humanizeWorkflowStatus.d.ts +2 -0
- package/dist/app/installResumeInstructions.d.ts +26 -0
- package/dist/app/installTerminalCrashCleanup.d.ts +26 -0
- package/dist/app/isLightTerminalBackground.d.ts +2 -0
- package/dist/app/layoutCodexFileDiff.d.ts +13 -0
- package/dist/app/looksLikeAuthenticationFailure.d.ts +5 -0
- package/dist/app/nearestXtermColorIndex.d.ts +2 -0
- package/dist/app/parseCodexMcpToolInvocation.d.ts +2 -0
- package/dist/app/parseSessionCommand.d.ts +6 -0
- package/dist/app/providerErrorResetAt.d.ts +2 -0
- package/dist/app/providerQuotaToStartupStatusUsage.d.ts +3 -0
- package/dist/app/readClipboardImage.d.ts +9 -0
- package/dist/app/renderActivityWave.d.ts +3 -0
- package/dist/app/renderAgentMarkdown.d.ts +8 -0
- package/dist/app/renderBackgroundTerminalCompletion.d.ts +1 -0
- package/dist/app/renderBackgroundTerminalInteraction.d.ts +2 -0
- package/dist/app/renderBackgroundTerminalSummary.d.ts +1 -0
- package/dist/app/renderChildRows.d.ts +13 -0
- package/dist/app/renderCodexDiffLine.d.ts +3 -0
- package/dist/app/renderCodexFileDiff.d.ts +7 -0
- package/dist/app/renderCodexMcpToolCall.d.ts +2 -0
- package/dist/app/renderCompletedTurnStats.d.ts +2 -0
- package/dist/app/renderExecCommand.d.ts +11 -0
- package/dist/app/renderExploration.d.ts +9 -0
- package/dist/app/renderFullscreenComponent.d.ts +8 -0
- package/dist/app/renderHappyTerminalBanner.d.ts +6 -0
- package/dist/app/renderHappyTerminalVersion.d.ts +1 -0
- package/dist/app/renderNoticeWithChildren.d.ts +7 -0
- package/dist/app/renderPendingSteeringMessages.d.ts +1 -0
- package/dist/app/renderStartupStatusCard.d.ts +7 -0
- package/dist/app/renderSubagentSummary.d.ts +6 -0
- package/dist/app/renderTurnCompletionSeparator.d.ts +1 -0
- package/dist/app/renderWorkflowSummary.d.ts +1 -0
- package/dist/app/resolveInputBackground.d.ts +3 -0
- package/dist/app/resolveStartupProviderQuota.d.ts +3 -0
- package/dist/app/resolveStartupSessionId.d.ts +18 -0
- package/dist/app/resolveTerminalColorLevel.d.ts +2 -0
- package/dist/app/resolveTerminalStyle.d.ts +1 -0
- package/dist/app/resolveTerminalTheme.d.ts +5 -0
- package/dist/app/runApp.d.ts +27 -0
- package/dist/app/runClipboardCommand.d.ts +8 -0
- package/dist/app/sanitizeTerminalText.d.ts +1 -0
- package/dist/app/shortenHomePath.d.ts +2 -0
- package/dist/app/sortSubagentsForDisplay.d.ts +2 -0
- package/dist/app/subagentElapsedMs.d.ts +2 -0
- package/dist/app/subagentLogMessageText.d.ts +2 -0
- package/dist/app/surfaceThemedLine.d.ts +3 -0
- package/dist/app/truncatePathToWidth.d.ts +1 -0
- package/dist/app/truncateTextForDisplay.d.ts +5 -0
- package/dist/app/truncateUtf8BytesForDisplay.d.ts +1 -0
- package/dist/app/upsertSubagentSummary.d.ts +2 -0
- package/dist/app/workflowDisplay.d.ts +2 -0
- package/dist/client/HappyAgentEventHub.d.ts +25 -0
- package/dist/client/RemoteAgent.d.ts +74 -0
- package/dist/client/RemoteAgentRunError.d.ts +4 -0
- package/dist/client/RemoteTerminalAttachment.d.ts +19 -0
- package/dist/client/RemoteTerminalClientReplica.d.ts +14 -0
- package/dist/client/ensureLocalProtocolServer.d.ts +10 -0
- package/dist/client/fetchProviderQuotas.d.ts +10 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/loadAgentCatalog.d.ts +32 -0
- package/dist/config/createProjectConfigSecurityNotice.d.ts +10 -0
- package/dist/config/defaultConfig.d.ts +2 -0
- package/dist/config/getDefaultGlobalConfigPath.d.ts +1 -0
- package/dist/config/getDefaultLocalConfigPath.d.ts +1 -0
- package/dist/config/getDefaultRuntimeConfigPath.d.ts +1 -0
- package/dist/config/getHappyConfigDirectory.d.ts +1 -0
- package/dist/config/getHappyTerminalHome.d.ts +1 -0
- package/dist/config/index.d.ts +15 -0
- package/dist/config/loadConfig.d.ts +2 -0
- package/dist/config/mergeConfigValues.d.ts +2 -0
- package/dist/config/parseConfigToml.d.ts +7 -0
- package/dist/config/readConfigFile.d.ts +2 -0
- package/dist/config/readProjectConfigFile.d.ts +2 -0
- package/dist/config/resolveConfigPaths.d.ts +2 -0
- package/dist/config/runtimeConfigLock.d.ts +1 -0
- package/dist/config/types.d.ts +74 -0
- package/dist/config/updateRuntimeConfig.d.ts +2 -0
- package/dist/config/updateRuntimePreferences.d.ts +2 -0
- package/dist/config/withoutProjectMachineSettings.d.ts +7 -0
- package/dist/config/writeRuntimeConfig.d.ts +3 -0
- package/dist/config/writeRuntimeConfigDefaults.d.ts +2 -0
- package/dist/daemon/createUnixSocketFetch.d.ts +2 -0
- package/dist/daemon/daemonPid.d.ts +11 -0
- package/dist/daemon/ensureHappyAgentBinary.d.ts +15 -0
- package/dist/daemon/ensureLocalProtocolServer.d.ts +24 -0
- package/dist/daemon/getHappyDaemonPaths.d.ts +16 -0
- package/dist/daemon/happyAgentBinaryConfig.d.ts +15 -0
- package/dist/daemon/index.d.ts +5 -0
- package/dist/daemon/runDaemonCommand.d.ts +4 -0
- package/dist/debug/DebugLog.d.ts +11 -0
- package/dist/debug/closeNodeInspector.d.ts +1 -0
- package/dist/debug/createDebugJsonReplacer.d.ts +1 -0
- package/dist/debug/createRequestDebugDirectory.d.ts +1 -0
- package/dist/debug/getDebugRootDirectory.d.ts +1 -0
- package/dist/debug/getNodeInspectorUrl.d.ts +1 -0
- package/dist/debug/index.d.ts +8 -0
- package/dist/debug/openNodeInspector.d.ts +1 -0
- package/dist/debug/registerHappyTerminalDebugRoot.d.ts +5 -0
- package/dist/docs/DESIGN.md +2191 -0
- package/dist/docs/README.md +93 -0
- package/dist/docs/agents-and-collaboration.md +365 -0
- package/dist/docs/architecture.md +468 -0
- package/dist/docs/extending.md +561 -0
- package/dist/docs/happy.md +386 -0
- package/dist/docs/permissions-and-sandbox.md +342 -0
- package/dist/docs/workspaces.md +281 -0
- package/dist/errorToMessage.d.ts +1 -0
- package/dist/formatCliFailure.d.ts +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +17151 -0
- package/dist/main.js +18946 -0
- package/dist/observability/daemonContext.d.ts +16 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/processes/BoundedOutputBuffer.d.ts +20 -0
- package/dist/processes/NativeProcessManager.d.ts +66 -0
- package/dist/processes/ProcessGroupReaper.d.ts +32 -0
- package/dist/processes/index.d.ts +7 -0
- package/dist/processes/isProcessRunning.d.ts +1 -0
- package/dist/processes/isTargetProcessAlive.d.ts +1 -0
- package/dist/processes/killProcessTree.d.ts +2 -0
- package/dist/processes/resolveSystemShell.d.ts +1 -0
- package/dist/processes/startProcessTransport.d.ts +33 -0
- package/dist/processes/types.d.ts +41 -0
- package/dist/processes/waitForProcessExit.d.ts +2 -0
- package/dist/protocol/AppletProtocol.d.ts +101 -0
- package/dist/protocol/Attachment.d.ts +96 -0
- package/dist/protocol/ClientProtocolTypes.d.ts +481 -0
- package/dist/protocol/DocumentProtocol.d.ts +109 -0
- package/dist/protocol/EventId.d.ts +1 -0
- package/dist/protocol/FolderProtocol.d.ts +236 -0
- package/dist/protocol/FolderSharingProtocol.d.ts +97 -0
- package/dist/protocol/GitHubPluginProtocol.d.ts +43 -0
- package/dist/protocol/GlobalSecurityProtocol.d.ts +7 -0
- package/dist/protocol/HappyCloudProtocol.d.ts +178 -0
- package/dist/protocol/InstallationProtocol.d.ts +80 -0
- package/dist/protocol/OnboardingProtocol.d.ts +47 -0
- package/dist/protocol/P2pCredentialProtocol.d.ts +273 -0
- package/dist/protocol/P2pIdentityProtocol.d.ts +8 -0
- package/dist/protocol/P2pPairingProtocol.d.ts +68 -0
- package/dist/protocol/P2pProtocol.d.ts +185 -0
- package/dist/protocol/PluginWireTypes.d.ts +81 -0
- package/dist/protocol/ProfileProtocol.d.ts +127 -0
- package/dist/protocol/ProjectFileProtocol.d.ts +63 -0
- package/dist/protocol/ProjectProtocol.d.ts +646 -0
- package/dist/protocol/ProtocolVersion.d.ts +8 -0
- package/dist/protocol/ServiceNotice.d.ts +175 -0
- package/dist/protocol/SessionProtocol.d.ts +1156 -0
- package/dist/protocol/SharingProtocol.d.ts +159 -0
- package/dist/protocol/SlotProtocol.d.ts +270 -0
- package/dist/protocol/TimelineProtocol.d.ts +88 -0
- package/dist/protocol/WorkletProtocol.d.ts +159 -0
- package/dist/protocol/createEventIdFactory.d.ts +7 -0
- package/dist/protocol/index.d.ts +34 -0
- package/dist/readPackageVersion.d.ts +1 -0
- package/dist/readPackageVersion.js +16 -0
- package/dist/reportCliFailure.d.ts +2 -0
- package/dist/runHappyTerminal.d.ts +6 -0
- package/dist/terminal/index.d.ts +2 -0
- package/dist/terminal/types.d.ts +39 -0
- package/dist/types.d.ts +6 -0
- package/dist/writeStderrSync.d.ts +5 -0
- package/package.json +97 -0
|
@@ -0,0 +1,2191 @@
|
|
|
1
|
+
# Happy (2) plugin app design
|
|
2
|
+
|
|
3
|
+
This is the authoritative visual specification for a **plugin app**: the HTML
|
|
4
|
+
document a Happy (2) plugin ships and Happy renders inside a sandboxed iframe
|
|
5
|
+
(an MCP App view, or an applet served on its own isolated origin).
|
|
6
|
+
|
|
7
|
+
It is written to be followed literally. Every rule states a name, a number, or a
|
|
8
|
+
condition. Where a choice exists, this document makes it for you. If you are a
|
|
9
|
+
model generating a plugin app and you are unsure what something should look
|
|
10
|
+
like, the answer is in here; do not invent one.
|
|
11
|
+
|
|
12
|
+
## 0. How to use this document
|
|
13
|
+
|
|
14
|
+
**Precedence.** Three contracts apply to a plugin app, in this order:
|
|
15
|
+
|
|
16
|
+
1. The **MCP Apps specification** (`@modelcontextprotocol/ext-apps`). It defines
|
|
17
|
+
the handshake, the host context, and the closed set of style variables. Never
|
|
18
|
+
violate it.
|
|
19
|
+
2. **This document.** It says how a Happy plugin app should look and behave
|
|
20
|
+
inside that specification.
|
|
21
|
+
3. Your plugin's own product decisions — content, information architecture,
|
|
22
|
+
feature set.
|
|
23
|
+
|
|
24
|
+
The repository's root `DESIGN.md` is the contract for **Happy's own native UI**,
|
|
25
|
+
written and reviewed inside the Happy codebase. It is _not_ the contract for a
|
|
26
|
+
plugin app and you do not need to read it. This document deliberately restates
|
|
27
|
+
every shared value (grid, radii, control heights, type scale) so a plugin app is
|
|
28
|
+
self-contained. Where a number appears in both, it is the same number.
|
|
29
|
+
|
|
30
|
+
**The one-sentence summary.** A Happy plugin app is a quiet, dense, neutral
|
|
31
|
+
desktop surface built from flex rows and columns on a 4 px grid, painted only
|
|
32
|
+
with host-provided CSS variables, with exactly one accent colour used only where
|
|
33
|
+
something is interactive or selected.
|
|
34
|
+
|
|
35
|
+
**Read at minimum:** §1 (host contract), §2 (surfaces and sizes), §5 (variable
|
|
36
|
+
reference), §6 (typography), §11.6 (what to render before you are connected),
|
|
37
|
+
§16 (anti-patterns), §18 (checklist). §17 is a complete working baseline you can
|
|
38
|
+
copy verbatim.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 1. The host contract
|
|
43
|
+
|
|
44
|
+
### 1.1 How appearance reaches your app
|
|
45
|
+
|
|
46
|
+
Your app runs in an iframe on a different origin from Happy. It cannot read
|
|
47
|
+
Happy's stylesheets, DOM, or fonts. Everything about appearance arrives as data
|
|
48
|
+
in the **host context**, over the MCP Apps postMessage bridge:
|
|
49
|
+
|
|
50
|
+
| Field | What it carries |
|
|
51
|
+
| ------------------------- | ----------------------------------------------------------------------- |
|
|
52
|
+
| `theme` | `"light"` or `"dark"` — the appearance Happy is currently rendering in. |
|
|
53
|
+
| `styles.variables` | The standard MCP Apps CSS variables, resolved from Happy's live theme. |
|
|
54
|
+
| `happy2/styles.variables` | Happy's extension: the roles the standard set has no key for (§5.3). |
|
|
55
|
+
| `displayMode` | `"inline"` or `"fullscreen"` — which surface you are on (§2). |
|
|
56
|
+
| `containerDimensions` | The space Happy allocated you **at connect time only** — see below. |
|
|
57
|
+
| `platform` | Always `"desktop"` in Happy. |
|
|
58
|
+
| `deviceCapabilities` | Always `{ touch: false, hover: true }` in Happy. |
|
|
59
|
+
| `locale`, `timeZone` | For formatting dates and numbers. Never hard-code a format. |
|
|
60
|
+
|
|
61
|
+
> **`containerDimensions` is not live.** Happy re-sends the host context when the
|
|
62
|
+
> appearance changes, not when the window resizes, so this value goes stale the
|
|
63
|
+
> moment the user drags the window edge. Use it to choose an initial layout at
|
|
64
|
+
> most. **Never lay out from it in JavaScript.** Your size comes from CSS —
|
|
65
|
+
> `height: 100%` and a flex column — and from container queries (§4.5).
|
|
66
|
+
|
|
67
|
+
The SDK applies all of this for you. In a React app:
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { useHappyApp } from "happy2-plugin-sdk/app";
|
|
71
|
+
|
|
72
|
+
function App() {
|
|
73
|
+
const { app, error, hostContext, isConnected } = useHappyApp({
|
|
74
|
+
appInfo: { name: "my-plugin-app", version: "1.0.0" },
|
|
75
|
+
autoResize: true,
|
|
76
|
+
});
|
|
77
|
+
// Nothing else is needed for theming.
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`useHappyApp` calls the official `useHostStyles`, which sets
|
|
82
|
+
`data-theme="light" | "dark"` and `style.colorScheme` on `<html>` and writes
|
|
83
|
+
every standard variable there as an inline custom property; and it calls
|
|
84
|
+
Happy's `useHappyStyleVariables`, which writes the `--happy-*` variables the
|
|
85
|
+
same way.
|
|
86
|
+
|
|
87
|
+
**Your first paint is not themed.** Connecting is an asynchronous handshake over
|
|
88
|
+
`postMessage`. Your app mounts and paints _before_ it completes — with
|
|
89
|
+
`isConnected === false` and not one host variable set — then repaints when the
|
|
90
|
+
context arrives, usually within a frame or two. This is why the fallback block in
|
|
91
|
+
§5.4 is mandatory rather than a nicety: without it the app flashes unstyled, and
|
|
92
|
+
in a host that never sends styles it stays that way. §11.6 says what to render
|
|
93
|
+
during the handshake.
|
|
94
|
+
|
|
95
|
+
### 1.2 The theme can change at any moment
|
|
96
|
+
|
|
97
|
+
Happy re-sends the host context whenever the resolved appearance changes: the
|
|
98
|
+
operating system switches between light and dark, or the user picks an explicit
|
|
99
|
+
appearance inside Happy. Your app is **not** remounted and **not** reloaded.
|
|
100
|
+
|
|
101
|
+
This has one hard consequence:
|
|
102
|
+
|
|
103
|
+
> **Every colour in your app must be a `var(--…)` reference that resolves through
|
|
104
|
+
> the host variables.** A colour you computed once in JavaScript, baked into a
|
|
105
|
+
> generated SVG, or hard-coded in a class name will be wrong the moment the theme
|
|
106
|
+
> changes, and there will be no second chance to fix it.
|
|
107
|
+
|
|
108
|
+
Corollaries:
|
|
109
|
+
|
|
110
|
+
- Do not read a variable with `getComputedStyle` and store the value.
|
|
111
|
+
- Do not branch on `theme` in JavaScript to pick a colour. Let CSS do it.
|
|
112
|
+
- Do not use `@media (prefers-color-scheme: …)` to pick colours. The user may
|
|
113
|
+
have overridden the appearance inside Happy, in which case the media query and
|
|
114
|
+
the host disagree and the host is right.
|
|
115
|
+
- **Do not write theme-conditional rules at all.** The variables already encode
|
|
116
|
+
the difference; a rule keyed on `[data-theme="dark"]` is both unnecessary and a
|
|
117
|
+
trap, because that attribute exists only after a host connects, so the rule
|
|
118
|
+
silently never fires in a standalone browser tab. If a value genuinely has no
|
|
119
|
+
variable — a hand-drawn illustration's two-tone fill, say — express it with
|
|
120
|
+
`light-dark()`, which works in both situations.
|
|
121
|
+
- `light-dark()` **is** safe: the host sets `color-scheme` on `<html>`, so
|
|
122
|
+
`light-dark(a, b)` follows the host's decision, including an in-app override.
|
|
123
|
+
This is the recommended way to write a _fallback_ (§5.4).
|
|
124
|
+
|
|
125
|
+
### 1.3 What the host will never give you
|
|
126
|
+
|
|
127
|
+
- Happy's fonts as files, its component CSS, its class names, or its icon fonts.
|
|
128
|
+
- Arbitrary CSS. There is no channel for the host to inject rules into your
|
|
129
|
+
document. Everything you paint, you paint yourself, from the variables.
|
|
130
|
+
- Any variable outside the two documented sets. Do not guess at names such as
|
|
131
|
+
`--happy-sidebar-width`; they do not exist and never resolve.
|
|
132
|
+
|
|
133
|
+
### 1.4 You are app content, not Happy chrome
|
|
134
|
+
|
|
135
|
+
Happy already draws the frame around you: a header carrying the app's glyph,
|
|
136
|
+
title, and controls (40 px on a message card, 56 px on a page or overlay); the
|
|
137
|
+
window title bar; the feature rail; the modal card and its scrim. Your document
|
|
138
|
+
is the region _inside_ that.
|
|
139
|
+
|
|
140
|
+
**Do not imitate host chrome.** Specifically, never render:
|
|
141
|
+
|
|
142
|
+
- A window title bar, traffic-light buttons, or a drag region.
|
|
143
|
+
- A duplicate of the app's own title in a bar across the top of your document.
|
|
144
|
+
Happy is already showing it, one hairline above you.
|
|
145
|
+
- A close, minimise, maximise, reload, or "open in new window" control.
|
|
146
|
+
- A global app-level navigation rail or sidebar of _Happy's_ features.
|
|
147
|
+
- A backdrop or scrim covering the whole viewport for a "modal" that is really
|
|
148
|
+
the whole app. If your app needs to be a dialog, request it from Happy.
|
|
149
|
+
|
|
150
|
+
You _may_ render a toolbar or a header **for your own content** — a list's
|
|
151
|
+
filter row, a document's breadcrumb — as long as it reads as part of the
|
|
152
|
+
content, not as a second window frame. See §9.6.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## 2. Surfaces, dimensions, and gutters
|
|
157
|
+
|
|
158
|
+
Happy is **desktop only**. There is no mobile viewport, no touch input, and no
|
|
159
|
+
phone layout. Do not write `@media (max-width: 480px)` breakpoints, do not
|
|
160
|
+
enlarge hit targets for fingers, and do not build a bottom tab bar.
|
|
161
|
+
|
|
162
|
+
### 2.1 The five surfaces
|
|
163
|
+
|
|
164
|
+
| # | Surface | `displayMode` | Host header | Host gutter around you | Typical width | Height ownership |
|
|
165
|
+
| --- | ----------------------- | ------------- | ----------- | ---------------------------------------------------------- | ----------------------------- | ---------------- |
|
|
166
|
+
| 1 | Message card (inline) | `inline` | 40 px | 0, or 8 px + 1 px hairline when the app asked for a border | the chat message column | **you**, 120–800 |
|
|
167
|
+
| 2 | Workspace page | `fullscreen` | 56 px | none, flush | main region width | the host |
|
|
168
|
+
| 3 | Modal card | `fullscreen` | 56 px | none, flush | `min(920px, window − 48) − 2` | the host |
|
|
169
|
+
| 4 | Fullscreen overlay | `fullscreen` | 56 px | none, flush | `window − 48 − 2` | the host |
|
|
170
|
+
| 5 | Happy Agent plugin page | `fullscreen` | none | none, flush | the whole allocated region | the host |
|
|
171
|
+
|
|
172
|
+
`hostContext.containerDimensions` reports the initial allocation as one of
|
|
173
|
+
`{ width, height }`, `{ width, maxHeight }`, or `{ maxWidth, maxHeight }`. It
|
|
174
|
+
does not track resizing — see the note in §1.1.
|
|
175
|
+
|
|
176
|
+
Surfaces 2–4 are flush: no inset, no border, no rounded corner from the host. The
|
|
177
|
+
seam between Happy's chrome and your document is one hairline, so your outermost
|
|
178
|
+
background should normally be `--color-background-primary` (§7.1), which makes
|
|
179
|
+
that seam invisible.
|
|
180
|
+
|
|
181
|
+
**You cannot tell surfaces 2–5 apart, and you must not need to.** All four report
|
|
182
|
+
`displayMode: "fullscreen"`; nothing in the host context says whether you are a
|
|
183
|
+
workspace page, a modal card, or a whole window. Design one fullscreen layout that
|
|
184
|
+
is correct at every size in §2.4 and it will be right on all four. The only
|
|
185
|
+
distinction that reaches you is `inline` versus `fullscreen`, and it matters for
|
|
186
|
+
exactly one reason: who owns your height (§2.2, §2.3).
|
|
187
|
+
|
|
188
|
+
### 2.2 Inline (surface 1) — you own your height
|
|
189
|
+
|
|
190
|
+
On a chat message card, Happy sizes the frame to the height your document
|
|
191
|
+
reports. The SDK does this for you when you pass `autoResize: true`.
|
|
192
|
+
|
|
193
|
+
- Default before you report: **360 px**.
|
|
194
|
+
- Clamped to **120 px minimum, 800 px maximum**. Content taller than 800 px
|
|
195
|
+
scrolls _inside_ your document; design for that, do not fight it.
|
|
196
|
+
- `containerDimensions` gives you `maxHeight: 800`.
|
|
197
|
+
|
|
198
|
+
Therefore: on inline, **`<body>` must size to its content**. Do not set
|
|
199
|
+
`height: 100%` or `100vh` on `html`/`body`, or you will report 800 px forever and
|
|
200
|
+
show a mostly empty card.
|
|
201
|
+
|
|
202
|
+
### 2.3 Fullscreen (surfaces 2–5) — the host owns your height
|
|
203
|
+
|
|
204
|
+
The frame is exactly the region Happy allocated. Your document must fill it and
|
|
205
|
+
manage its own overflow.
|
|
206
|
+
|
|
207
|
+
```css
|
|
208
|
+
html,
|
|
209
|
+
body {
|
|
210
|
+
height: 100%;
|
|
211
|
+
margin: 0;
|
|
212
|
+
}
|
|
213
|
+
#root {
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
height: 100%;
|
|
217
|
+
min-height: 0;
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### 2.4 The smallest size you must survive
|
|
222
|
+
|
|
223
|
+
Happy's Electron window minimum is **720 × 480**. Subtract the 64 px feature
|
|
224
|
+
rail, a navigation sidebar that can be as narrow as 250 px, the 56 px window
|
|
225
|
+
title row, and the 56 px host app header, and the honest floor is:
|
|
226
|
+
|
|
227
|
+
> **A plugin app must remain usable and unbroken at 400 × 360 CSS pixels, and
|
|
228
|
+
> must look correct at 640 × 480 and above.**
|
|
229
|
+
|
|
230
|
+
Design the primary layout for **640–1180 px** wide. Below 640 px, collapse
|
|
231
|
+
optional columns; do not introduce a different design.
|
|
232
|
+
|
|
233
|
+
### 2.5 Gutters
|
|
234
|
+
|
|
235
|
+
Except for the optional 8 px on a bordered inline card, the host region is flush,
|
|
236
|
+
so your document supplies its own gutter:
|
|
237
|
+
|
|
238
|
+
| Context | Gutter (padding) |
|
|
239
|
+
| ----------------------------------------- | ---------------- |
|
|
240
|
+
| Inline message card, outer edge | **12 px** |
|
|
241
|
+
| Workspace page / overlay, outer edge | **16 px** |
|
|
242
|
+
| Wide page (≥ 900 px), outer edge | **24 px** |
|
|
243
|
+
| Inside a card | **16 px** |
|
|
244
|
+
| Inside a card in a dense card grid | **12 px** |
|
|
245
|
+
| Inside a compact list row | **8 px 12 px** |
|
|
246
|
+
| Between a section heading and its content | **8 px** |
|
|
247
|
+
|
|
248
|
+
Never use an outer gutter larger than 24 px. Never use 0 — content must not
|
|
249
|
+
touch the frame edge.
|
|
250
|
+
|
|
251
|
+
If your content has a natural reading width, cap the _inner_ measure and centre
|
|
252
|
+
it; keep the scroll container full-bleed (§4).
|
|
253
|
+
|
|
254
|
+
```css
|
|
255
|
+
.page-measure {
|
|
256
|
+
width: 100%;
|
|
257
|
+
max-width: 720px;
|
|
258
|
+
margin-inline: auto;
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## 3. The grid: 4 / 8 / 16
|
|
265
|
+
|
|
266
|
+
The scale applies to **layout spacing**: the `gap` between siblings, the padding
|
|
267
|
+
of a container around its children, the page gutter, and the space between
|
|
268
|
+
blocks. Every such value is one of:
|
|
269
|
+
|
|
270
|
+
| Value | Use |
|
|
271
|
+
| ----- | ------------------------------------------------------- |
|
|
272
|
+
| 4 px | Tight vertical pairs, chip-to-chip |
|
|
273
|
+
| 8 px | **The default gap.** Between controls, between cards |
|
|
274
|
+
| 12 px | Between a row's columns, between fields in a dense form |
|
|
275
|
+
| 16 px | Between content blocks, card padding, page gutter |
|
|
276
|
+
| 24 px | Between major sections, wide page gutter |
|
|
277
|
+
| 32 px | Around an empty state, above a page's first section |
|
|
278
|
+
| 48 px | Rare; vertical breathing room in a nearly empty page |
|
|
279
|
+
|
|
280
|
+
**Control internals are not on this scale.** The padding inside a button, the
|
|
281
|
+
gap between a button's icon and its label, the padding inside an input, the
|
|
282
|
+
height of a chip — these are fixed per component in §9 and are whatever number
|
|
283
|
+
makes the component's declared height come out exactly right. Copy them from §9
|
|
284
|
+
verbatim; do not "correct" a `padding: 0 14px` to `0 16px`.
|
|
285
|
+
|
|
286
|
+
Rules:
|
|
287
|
+
|
|
288
|
+
- `0` is always allowed, and is the right answer more often than it looks: a
|
|
289
|
+
stacked pair of text lines is separated by its line boxes, not by a `gap`, and a
|
|
290
|
+
hairline-separated list uses `gap: 0` (§8). Anything greater than zero is one of
|
|
291
|
+
the seven values above.
|
|
292
|
+
- Every length is a whole number of CSS pixels. Never use `em`/`rem` for spacing,
|
|
293
|
+
and never a fractional pixel.
|
|
294
|
+
- 1 px is for hairline borders. 2 px is for a focus ring and its offset.
|
|
295
|
+
- Border-box everywhere: `*, *::before, *::after { box-sizing: border-box; }`.
|
|
296
|
+
- A declared size must be the rendered size. If a row promises 36 px, its
|
|
297
|
+
padding, border, and line height must add to 36 px.
|
|
298
|
+
- Prefer integer positions so 1 px borders stay crisp at 2× device scale.
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## 4. Layout: flexbox, and scrolling
|
|
303
|
+
|
|
304
|
+
### 4.1 Flexbox is the default
|
|
305
|
+
|
|
306
|
+
Use flexbox for every row, column, stack, toolbar, list, and centred box.
|
|
307
|
+
|
|
308
|
+
```css
|
|
309
|
+
.row {
|
|
310
|
+
display: flex;
|
|
311
|
+
align-items: center;
|
|
312
|
+
gap: 8px;
|
|
313
|
+
}
|
|
314
|
+
.column {
|
|
315
|
+
display: flex;
|
|
316
|
+
flex-direction: column;
|
|
317
|
+
gap: 8px;
|
|
318
|
+
}
|
|
319
|
+
.center {
|
|
320
|
+
display: flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
justify-content: center;
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Use CSS Grid **only** for a genuine two-dimensional grid of related tracks: a
|
|
327
|
+
data table's cells, or a media matrix with equal row and column tracks. When you
|
|
328
|
+
do, put a comment beside the declaration explaining the geometry flexbox cannot
|
|
329
|
+
express.
|
|
330
|
+
|
|
331
|
+
Never use floats, `inline-block` spacing hacks, layout tables, or absolute
|
|
332
|
+
positioning for layout. Absolute positioning is for overlays, popovers, and
|
|
333
|
+
badges that intentionally leave the flow.
|
|
334
|
+
|
|
335
|
+
### 4.2 The parent owns the spacing
|
|
336
|
+
|
|
337
|
+
Spacing between siblings belongs to the flex parent, via `gap`. A child never
|
|
338
|
+
gives itself an external margin to separate itself from a sibling it does not
|
|
339
|
+
know about.
|
|
340
|
+
|
|
341
|
+
```css
|
|
342
|
+
/* Correct */
|
|
343
|
+
.toolbar {
|
|
344
|
+
display: flex;
|
|
345
|
+
gap: 8px;
|
|
346
|
+
}
|
|
347
|
+
.toolbar > * {
|
|
348
|
+
margin: 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* Wrong */
|
|
352
|
+
.toolbar-button + .toolbar-button {
|
|
353
|
+
margin-left: 8px;
|
|
354
|
+
}
|
|
355
|
+
.toolbar-button:not(:last-child) {
|
|
356
|
+
margin-right: 8px;
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**The one legitimate margin** is `margin-inline-start: auto` on a flex child, to
|
|
361
|
+
push it and everything after it to the far end of the row. That is positioning,
|
|
362
|
+
not spacing, and it is the correct idiom — do not simulate it with a spacer
|
|
363
|
+
element, and do not reach for `justify-content: space-between`, which spreads
|
|
364
|
+
_every_ child rather than only the tail.
|
|
365
|
+
|
|
366
|
+
```css
|
|
367
|
+
/* Preferred: let the middle column absorb the slack. */
|
|
368
|
+
.toolbar-title {
|
|
369
|
+
flex: 1 1 auto;
|
|
370
|
+
min-width: 0;
|
|
371
|
+
}
|
|
372
|
+
/* Or push the tail explicitly when there is no such column. */
|
|
373
|
+
.toolbar-count {
|
|
374
|
+
margin-inline-start: auto;
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
A conditional child must live in the same flex flow as its neighbours. When it is
|
|
379
|
+
absent it contributes neither a box nor a gap; when it appears, the parent's
|
|
380
|
+
`gap` separates it on both sides automatically. Do not wrap optional content in a
|
|
381
|
+
nested div that creates its own spacing island.
|
|
382
|
+
|
|
383
|
+
### 4.3 A flex child that can shrink needs `min-width: 0`
|
|
384
|
+
|
|
385
|
+
This is the single most common layout bug. A flex item's default minimum size is
|
|
386
|
+
its content, so long text pushes its siblings out of the row instead of
|
|
387
|
+
ellipsizing.
|
|
388
|
+
|
|
389
|
+
```css
|
|
390
|
+
.row-main {
|
|
391
|
+
flex: 1 1 auto;
|
|
392
|
+
min-width: 0;
|
|
393
|
+
} /* the column that may shrink */
|
|
394
|
+
.row-title {
|
|
395
|
+
overflow: hidden;
|
|
396
|
+
text-overflow: ellipsis;
|
|
397
|
+
white-space: nowrap;
|
|
398
|
+
}
|
|
399
|
+
.row-aside {
|
|
400
|
+
flex: none;
|
|
401
|
+
} /* fixed-size trailing content */
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The same applies vertically: a flex child that contains a scroll area needs
|
|
405
|
+
`min-height: 0`.
|
|
406
|
+
|
|
407
|
+
### 4.4 Scrolling
|
|
408
|
+
|
|
409
|
+
Exactly **one** element in a fullscreen layout owns vertical scrolling. It fills
|
|
410
|
+
the region its parent gives it and has **zero padding and zero margin** — its
|
|
411
|
+
viewport and scrollbar run edge to edge. All spacing, maximum widths, and
|
|
412
|
+
centring belong to an inner wrapper.
|
|
413
|
+
|
|
414
|
+
```css
|
|
415
|
+
.scrollport {
|
|
416
|
+
flex: 1 1 auto;
|
|
417
|
+
min-height: 0;
|
|
418
|
+
width: 100%;
|
|
419
|
+
overflow-y: auto;
|
|
420
|
+
overflow-x: hidden;
|
|
421
|
+
/* no padding, no margin */
|
|
422
|
+
}
|
|
423
|
+
.scrollport-content {
|
|
424
|
+
display: flex;
|
|
425
|
+
flex-direction: column;
|
|
426
|
+
gap: 12px;
|
|
427
|
+
padding: 16px;
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Rules:
|
|
432
|
+
|
|
433
|
+
- Never put `overflow: auto` on `<body>` in a fullscreen surface; give it to the
|
|
434
|
+
designated scrollport so the header and footer stay fixed.
|
|
435
|
+
- Never nest scroll areas on the same axis. Two vertical scrollbars in one view
|
|
436
|
+
is a defect.
|
|
437
|
+
- Horizontal scrolling is allowed only for a wide data table, and only on the
|
|
438
|
+
table's own container.
|
|
439
|
+
- Keep the full painted extent of a focus ring inside the content wrapper's
|
|
440
|
+
padding, so scrolling cannot clip it. A 2 px ring with a 2 px offset needs at
|
|
441
|
+
least 4 px of clearance — the 16 px gutter covers it.
|
|
442
|
+
- Style the scrollbar with the host thumb colour, or leave it native. Do not hide
|
|
443
|
+
it.
|
|
444
|
+
|
|
445
|
+
```css
|
|
446
|
+
.scrollport {
|
|
447
|
+
scrollbar-width: thin;
|
|
448
|
+
scrollbar-color: var(--happy-scrollbar-thumb, rgb(150 150 150 / 0.4)) transparent;
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### 4.5 Responsive behaviour inside a desktop panel
|
|
453
|
+
|
|
454
|
+
Respond to the **container**, not the viewport, and only to collapse or reflow —
|
|
455
|
+
never to switch to a different design.
|
|
456
|
+
|
|
457
|
+
```css
|
|
458
|
+
.page {
|
|
459
|
+
container-type: inline-size;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* Below 640px the metadata column folds under the main column. */
|
|
463
|
+
@container (max-width: 640px) {
|
|
464
|
+
.split {
|
|
465
|
+
flex-direction: column;
|
|
466
|
+
}
|
|
467
|
+
.split-aside {
|
|
468
|
+
width: 100%;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Permitted responses to a narrow container, in order of preference:
|
|
474
|
+
|
|
475
|
+
1. Let a flex row wrap (`flex-wrap: wrap`) at a documented gap.
|
|
476
|
+
2. Hide a genuinely secondary column (never the primary action).
|
|
477
|
+
3. Reduce a 3-column card grid to 2, then 1.
|
|
478
|
+
4. Swap a side-by-side label/field pair to stacked.
|
|
479
|
+
|
|
480
|
+
Forbidden: hiding the primary action, replacing the layout with a "mobile" one,
|
|
481
|
+
introducing a hamburger menu, shrinking type below the minimums in §6.
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## 5. Colour: the complete variable reference
|
|
486
|
+
|
|
487
|
+
**Every colour in your app comes from this section.** A raw hex value, named
|
|
488
|
+
colour, or `rgb()` literal in your CSS is a defect, with exactly one exception:
|
|
489
|
+
the fallback inside `var(--x, fallback)` (§5.4).
|
|
490
|
+
|
|
491
|
+
The two schemes are Happy's: light is white and `#f5f5f5` neutrals with black
|
|
492
|
+
primary actions; dark is a `#1e1e1e` canvas with `#212121` surfaces and, again,
|
|
493
|
+
black primary actions. You never write those values — they are given here only so
|
|
494
|
+
you can predict what a variable will look like.
|
|
495
|
+
|
|
496
|
+
### 5.1 Standard MCP Apps variables — colour
|
|
497
|
+
|
|
498
|
+
Set on `<html>` by the official host-styles hook. Resolved live from Happy's
|
|
499
|
+
theme, so they change with the appearance.
|
|
500
|
+
|
|
501
|
+
| Variable | Meaning in Happy | Light | Dark |
|
|
502
|
+
| ------------------------------ | ---------------------------------------------------------------------- | ------------- | ------------- |
|
|
503
|
+
| `--color-background-primary` | The surface a card or page body sits on | `#ffffff` | `#212121` |
|
|
504
|
+
| `--color-background-secondary` | A recessed panel: table head, sidebar, code well | `#f8f8f8` | `#171717` |
|
|
505
|
+
| `--color-background-tertiary` | A pressed/active fill, or a deeper inset | `#f0f0f2` | `#2c2c2e` |
|
|
506
|
+
| `--color-background-inverse` | The primary-action fill (black in both schemes) | `#000000` | `#000000` |
|
|
507
|
+
| `--color-background-ghost` | Hover wash over any surface (translucent) | 8 % black | 8 % white |
|
|
508
|
+
| `--color-background-info` | Soft informational fill (12 % accent over surface) | pale blue | dim blue |
|
|
509
|
+
| `--color-background-success` | Soft success fill (14 % green over surface) | pale green | dim green |
|
|
510
|
+
| `--color-background-warning` | Soft warning fill | `#fff8f0` | 15 % orange |
|
|
511
|
+
| `--color-background-danger` | Soft error fill | `#fff0f0` | 15 % red |
|
|
512
|
+
| `--color-background-disabled` | A disabled control's fill | `#f0f0f2` | `#2c2c2e` |
|
|
513
|
+
| `--color-text-primary` | Body and heading text | `#000000` | `#ffffff` |
|
|
514
|
+
| `--color-text-secondary` | Supporting text, labels, captions | `#49454f` | `#cac4d0` |
|
|
515
|
+
| `--color-text-tertiary` | Metadata, timestamps, counts: 70 % of secondary over the surface | `#807d84` | `#97939c` |
|
|
516
|
+
| `--color-text-inverse` | Text on `--color-background-inverse` | `#ffffff` | `#ffffff` |
|
|
517
|
+
| `--color-text-ghost` | Text on a ghost/hover fill | `#49454f` | `#cac4d0` |
|
|
518
|
+
| `--color-text-info` | Informational text and the accent for text | `#007aff` | `#0a84ff` |
|
|
519
|
+
| `--color-text-success` | Success text | `#34c759` | `#32d74b` |
|
|
520
|
+
| `--color-text-warning` | Warning text | `#ff9500` | `#ffab00` |
|
|
521
|
+
| `--color-text-danger` | Destructive/error text | `#f44336` | `#f48fb1` |
|
|
522
|
+
| `--color-text-disabled` | Disabled label: 45 % of secondary over the surface | `#adabb0` | `#6d6a70` |
|
|
523
|
+
| `--color-border-primary` | **The default hairline.** Card, row, input border | `#eaeaea` | `#292929` |
|
|
524
|
+
| `--color-border-secondary` | The one divider stronger than the hairline: 20 % text over the surface | `#cccccc` | `#4d4d4d` |
|
|
525
|
+
| `--color-border-tertiary` | Identical to primary. Use primary. | `#eaeaea` | `#292929` |
|
|
526
|
+
| `--color-border-inverse` | Border on an inverse fill | `#000000` | `#000000` |
|
|
527
|
+
| `--color-border-ghost` | `transparent`, for a border that only reserves space | `transparent` | `transparent` |
|
|
528
|
+
| `--color-border-info` | Informational outline | `#007aff` | `#0a84ff` |
|
|
529
|
+
| `--color-border-success` | Success outline | `#34c759` | `#32d74b` |
|
|
530
|
+
| `--color-border-warning` | Warning outline | `#ff9500` | `#ff9f0a` |
|
|
531
|
+
| `--color-border-danger` | Error outline | `#f44336` | `#f48fb1` |
|
|
532
|
+
| `--color-border-disabled` | Disabled control outline | `#eaeaea` | `#292929` |
|
|
533
|
+
| `--color-ring-primary` | **The focus ring and the interactive accent** | `#007aff` | `#0a84ff` |
|
|
534
|
+
| `--color-ring-secondary` | A neutral ring, the secondary text colour | `#49454f` | `#cac4d0` |
|
|
535
|
+
| `--color-ring-inverse` | Ring against an inverse fill | `#000000` | `#000000` |
|
|
536
|
+
| `--color-ring-info` | Same as ring-primary | `#007aff` | `#0a84ff` |
|
|
537
|
+
| `--color-ring-success` | Success ring | `#34c759` | `#32d74b` |
|
|
538
|
+
| `--color-ring-warning` | Warning ring | `#ff9500` | `#ff9f0a` |
|
|
539
|
+
| `--color-ring-danger` | Error ring | `#f44336` | `#f48fb1` |
|
|
540
|
+
|
|
541
|
+
### 5.2 Standard MCP Apps variables — type, shape, elevation
|
|
542
|
+
|
|
543
|
+
| Variable | Value | Use |
|
|
544
|
+
| ---------------------------------------------------------- | -------------------------- | ----------------------------------------- |
|
|
545
|
+
| `--font-sans` | Happy's UI stack | All interface text |
|
|
546
|
+
| `--font-mono` | Happy's mono stack | Code, IDs, hashes, log output |
|
|
547
|
+
| `--font-weight-normal` / `-medium` / `-semibold` / `-bold` | 400 / 500 / 600 / 700 | See §6 |
|
|
548
|
+
| `--font-text-xs-size` / `-line-height` | 11 / 16 px | Micro-labels, table column heads |
|
|
549
|
+
| `--font-text-sm-size` / `-line-height` | 12 / 18 px | Captions, metadata, chips |
|
|
550
|
+
| `--font-text-md-size` / `-line-height` | 14 / 20 px | **Body default** |
|
|
551
|
+
| `--font-text-lg-size` / `-line-height` | 16 / 24 px | Lead paragraph, prominent value |
|
|
552
|
+
| `--font-heading-xs-size` / `-line-height` | 13 / 18 px | Section label |
|
|
553
|
+
| `--font-heading-sm-size` / `-line-height` | 15 / 20 px | Card title, row title |
|
|
554
|
+
| `--font-heading-md-size` / `-line-height` | 17 / 22 px | Page title on a small surface |
|
|
555
|
+
| `--font-heading-lg-size` / `-line-height` | 20 / 26 px | Page title |
|
|
556
|
+
| `--font-heading-xl-size` / `-line-height` | 24 / 30 px | Rare; a landing or empty-state headline |
|
|
557
|
+
| `--font-heading-2xl-size` / `-line-height` | 28 / 34 px | Do not use in a plugin app |
|
|
558
|
+
| `--font-heading-3xl-size` / `-line-height` | 34 / 40 px | Do not use in a plugin app |
|
|
559
|
+
| `--border-radius-xs` | 6 px | Chips, small inputs |
|
|
560
|
+
| `--border-radius-sm` | 6 px | **Controls: buttons, inputs, menu items** |
|
|
561
|
+
| `--border-radius-md` | 8 px | **Content blocks, wells, list rows** |
|
|
562
|
+
| `--border-radius-lg` | 10 px | **Cards** |
|
|
563
|
+
| `--border-radius-xl` | 14 px | Large shells and dialog-like panels |
|
|
564
|
+
| `--border-radius-full` | 999 px | Pills, avatars, progress tracks |
|
|
565
|
+
| `--border-width-regular` | 1 px | Every hairline |
|
|
566
|
+
| `--shadow-hairline` | `0 0 0 1px <hairline>` | A ring instead of a border |
|
|
567
|
+
| `--shadow-sm` | `0 1px 2px <10 % black>` | A card lifted off the surface |
|
|
568
|
+
| `--shadow-md` | `0 4px 12px <24 % black>` | A popover or dropdown |
|
|
569
|
+
| `--shadow-lg` | `0 12px 32px <45 % black>` | An in-app dialog |
|
|
570
|
+
|
|
571
|
+
### 5.3 Happy's extension: `--happy-*`
|
|
572
|
+
|
|
573
|
+
Delivered under the namespaced `happy2/styles` host-context member and applied by
|
|
574
|
+
the SDK. These are the roles the standard vocabulary has no key for. They are all
|
|
575
|
+
live theme values and follow appearance changes exactly like the standard ones.
|
|
576
|
+
|
|
577
|
+
| Variable | Meaning | Light | Dark |
|
|
578
|
+
| ----------------------------- | ------------------------------------------------------------------ | ---------- | ---------- |
|
|
579
|
+
| `--happy-canvas` | The app canvas **behind** cards; use when your page is a card list | `#f5f5f5` | `#1e1e1e` |
|
|
580
|
+
| `--happy-header-background` | A content header strip's fill (your own, not the host's) | `#ffffff` | `#212121` |
|
|
581
|
+
| `--happy-header-text` | Text on that strip | `#18171c` | `#ffffff` |
|
|
582
|
+
| `--happy-selected-background` | The fill of a **selected** row or tab | `#eaeaea` | `#2c2c2e` |
|
|
583
|
+
| `--happy-link` | Hyperlink text (Happy teal, identical in both schemes) | `#2baccc` | `#2baccc` |
|
|
584
|
+
| `--happy-input-background` | A text field's fill | `#f5f5f5` | `#303030` |
|
|
585
|
+
| `--happy-input-text` | A text field's value | `#000000` | `#ffffff` |
|
|
586
|
+
| `--happy-input-placeholder` | A text field's placeholder | `#999999` | `#8e8e93` |
|
|
587
|
+
| `--happy-code-background` | The code/diff/log surface | `#f6f8fa` | `#161b22` |
|
|
588
|
+
| `--happy-scrim` | Backdrop behind an in-app overlay you own | 48 % black | 48 % black |
|
|
589
|
+
| `--happy-scrollbar-thumb` | Custom scrollbar thumb | 40 % grey | 40 % grey |
|
|
590
|
+
| `--happy-shadow-color` | The base elevation tint, for a shadow you compose yourself | 10 % black | 10 % black |
|
|
591
|
+
|
|
592
|
+
No other `--happy-*` name exists — the SDK applies these twelve names and nothing
|
|
593
|
+
else, discards any value that is not a colour, and removes a variable that a later
|
|
594
|
+
host context stops sending. If you need a role that is not listed, build it from
|
|
595
|
+
the ones that are, with `color-mix`:
|
|
596
|
+
|
|
597
|
+
```css
|
|
598
|
+
/* A 12% accent wash for a selected-and-focused row. */
|
|
599
|
+
background: color-mix(in srgb, var(--color-ring-primary) 12%, var(--color-background-primary));
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### 5.4 Fallbacks: declare them once, in one place
|
|
603
|
+
|
|
604
|
+
Every host variable can be absent — when your app is opened outside Happy, in a
|
|
605
|
+
bare browser tab during development, or by a host that has not sent styles yet.
|
|
606
|
+
Declare a fallback for each variable you use **exactly once**, in a `:root` block
|
|
607
|
+
that maps host names to your app's own short names. Downstream CSS then uses your
|
|
608
|
+
names with no fallbacks at all.
|
|
609
|
+
|
|
610
|
+
```css
|
|
611
|
+
:root {
|
|
612
|
+
/* Standalone documents follow the OS; inside Happy the host has already set
|
|
613
|
+
`color-scheme`, so `light-dark()` below follows the host's decision. */
|
|
614
|
+
color-scheme: light dark;
|
|
615
|
+
|
|
616
|
+
--app-surface: var(--color-background-primary, light-dark(#ffffff, #212121));
|
|
617
|
+
--app-raised: var(--color-background-secondary, light-dark(#f8f8f8, #171717));
|
|
618
|
+
--app-canvas: var(--happy-canvas, light-dark(#f5f5f5, #1e1e1e));
|
|
619
|
+
--app-text: var(--color-text-primary, light-dark(#000000, #ffffff));
|
|
620
|
+
--app-muted: var(--color-text-secondary, light-dark(#49454f, #cac4d0));
|
|
621
|
+
--app-border: var(--color-border-primary, light-dark(#eaeaea, #292929));
|
|
622
|
+
--app-accent: var(--color-ring-primary, light-dark(#007aff, #0a84ff));
|
|
623
|
+
--app-radius-control: var(--border-radius-sm, 6px);
|
|
624
|
+
--app-radius-card: var(--border-radius-lg, 10px);
|
|
625
|
+
--app-font: var(--font-sans, system-ui, -apple-system, sans-serif);
|
|
626
|
+
--app-mono: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
Two rules about this block:
|
|
631
|
+
|
|
632
|
+
- **Never alias a variable to itself.** `--happy-canvas: var(--happy-canvas, #f5f5f5)`
|
|
633
|
+
is a cycle; the property becomes invalid and everything using it falls back to
|
|
634
|
+
its initial value. Always map a host name to a _different_ local name.
|
|
635
|
+
- **Never repeat a fallback downstream.** `background: var(--app-surface)` — not
|
|
636
|
+
`var(--app-surface, #fff)`. One place to change, one place to be wrong.
|
|
637
|
+
- The component snippets in §9 write `var(--host-name, fallback)` inline so each
|
|
638
|
+
one can be read and copied on its own. That is a documentation convenience, not
|
|
639
|
+
the pattern. When you assemble an app, alias every host variable you use in the
|
|
640
|
+
`:root` block once — §17's block is the complete list — and rewrite the §9
|
|
641
|
+
snippets to use your local names with no fallback.
|
|
642
|
+
|
|
643
|
+
### 5.5 Contrast
|
|
644
|
+
|
|
645
|
+
- Body text on any surface: at least **4.5 : 1**.
|
|
646
|
+
- Text at 18 px+ or 14 px+ bold, and any non-text indicator (borders that carry
|
|
647
|
+
meaning, focus rings, chart strokes): at least **3 : 1**.
|
|
648
|
+
- The host variables already satisfy this for the documented pairings — primary
|
|
649
|
+
and secondary text on primary/secondary/tertiary backgrounds. If you invent a
|
|
650
|
+
pairing with `color-mix`, measure it.
|
|
651
|
+
- Never put `--color-text-tertiary` or `--color-text-disabled` on anything other
|
|
652
|
+
than `--color-background-primary` or `--color-background-secondary`.
|
|
653
|
+
- Never rely on colour alone. A status must also carry a word or a glyph.
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## 6. Typography
|
|
658
|
+
|
|
659
|
+
### 6.1 Families
|
|
660
|
+
|
|
661
|
+
```css
|
|
662
|
+
body {
|
|
663
|
+
font-family: var(--app-font);
|
|
664
|
+
}
|
|
665
|
+
code,
|
|
666
|
+
pre,
|
|
667
|
+
.mono {
|
|
668
|
+
font-family: var(--app-mono);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
`--font-sans` resolves to Happy's UI stack and `--font-mono` to its code stack.
|
|
673
|
+
The font _files_ are not available to your origin, so the stack ends in system
|
|
674
|
+
fonts; that is expected and correct. Do **not** load a web font, do not use
|
|
675
|
+
`@import url(https://fonts.googleapis.com/…)`, and do not embed a font as a data
|
|
676
|
+
URL. A plugin app that ships its own typeface looks foreign and costs the user a
|
|
677
|
+
network request from a sandboxed origin.
|
|
678
|
+
|
|
679
|
+
Set `font-synthesis: none` on `body` so a missing weight is not faked.
|
|
680
|
+
|
|
681
|
+
### 6.2 The scale
|
|
682
|
+
|
|
683
|
+
Use these seven roles and nothing else. Always set `font-size` and `line-height`
|
|
684
|
+
together, both from the same row.
|
|
685
|
+
|
|
686
|
+
| Role | Size / line height | Weight | Colour | Use |
|
|
687
|
+
| ------------- | ------------------ | ------ | ------------------------ | ------------------------------------- |
|
|
688
|
+
| Page title | 20 / 26 px | 600 | `--color-text-primary` | One per document, at most |
|
|
689
|
+
| Section title | 15 / 20 px | 600 | `--color-text-primary` | Card and group headings |
|
|
690
|
+
| Section label | 13 / 18 px | 600 | `--color-text-secondary` | Small caps-free group label |
|
|
691
|
+
| Body | 14 / 20 px | 400 | `--color-text-primary` | **The default for everything** |
|
|
692
|
+
| Row title | 14 / 20 px | 500 | `--color-text-primary` | The first line of a list row |
|
|
693
|
+
| Body strong | 14 / 20 px | 600 | `--color-text-primary` | A value being emphasised in prose |
|
|
694
|
+
| Caption | 12 / 18 px | 400 | `--color-text-secondary` | Secondary line under a title |
|
|
695
|
+
| Micro | 11 / 16 px | 500 | `--color-text-tertiary` | Table column heads, chip text, counts |
|
|
696
|
+
|
|
697
|
+
- **11 px is the absolute minimum.** Never render text smaller.
|
|
698
|
+
- Weight vocabulary is 400, 500, 600 only. Do not use 700 in a plugin app; do not
|
|
699
|
+
use 300 or lighter.
|
|
700
|
+
- Do not use `text-transform: uppercase` on anything longer than a three-word
|
|
701
|
+
label, and never with letter-spacing above `0.04em`.
|
|
702
|
+
- `letter-spacing: -0.01em` is permitted on the page title and section title.
|
|
703
|
+
Nowhere else.
|
|
704
|
+
- Never justify text. Never centre a paragraph. Centre only a single-line label
|
|
705
|
+
inside a control or an empty state.
|
|
706
|
+
|
|
707
|
+
### 6.3 Long text
|
|
708
|
+
|
|
709
|
+
```css
|
|
710
|
+
.truncate {
|
|
711
|
+
overflow: hidden;
|
|
712
|
+
text-overflow: ellipsis;
|
|
713
|
+
white-space: nowrap;
|
|
714
|
+
}
|
|
715
|
+
.wrap-anywhere {
|
|
716
|
+
overflow-wrap: anywhere;
|
|
717
|
+
} /* URLs, IDs, file paths */
|
|
718
|
+
.clamp-2 {
|
|
719
|
+
display: -webkit-box;
|
|
720
|
+
-webkit-box-orient: vertical;
|
|
721
|
+
-webkit-line-clamp: 2;
|
|
722
|
+
overflow: hidden;
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
Every single-line title in a row or card must truncate, and its flex parent must
|
|
727
|
+
carry `min-width: 0` (§4.3). Text that can contain an unbroken token — a URL, a
|
|
728
|
+
hash, a path — must use `overflow-wrap: anywhere` or truncate; it must never
|
|
729
|
+
widen its container.
|
|
730
|
+
|
|
731
|
+
### 6.4 Numbers
|
|
732
|
+
|
|
733
|
+
Right-align numeric table columns and use tabular figures so digits line up:
|
|
734
|
+
|
|
735
|
+
```css
|
|
736
|
+
.num {
|
|
737
|
+
font-variant-numeric: tabular-nums;
|
|
738
|
+
text-align: right;
|
|
739
|
+
}
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
Format dates, times, and numbers with `Intl`, passing `hostContext.locale` and
|
|
743
|
+
`hostContext.timeZone`. Never hard-code `MM/DD/YYYY` or a currency symbol.
|
|
744
|
+
|
|
745
|
+
---
|
|
746
|
+
|
|
747
|
+
## 7. Surfaces, borders, radii, shadows
|
|
748
|
+
|
|
749
|
+
### 7.1 Choosing a background
|
|
750
|
+
|
|
751
|
+
| You are painting | Background |
|
|
752
|
+
| ------------------------------------------------- | ------------------------------ |
|
|
753
|
+
| The document, when the content **is** the surface | `--color-background-primary` |
|
|
754
|
+
| The document, when it holds a list of cards | `--happy-canvas` |
|
|
755
|
+
| A card on the canvas | `--color-background-primary` |
|
|
756
|
+
| A recessed panel: table header, sidebar, well | `--color-background-secondary` |
|
|
757
|
+
| A code, log, or diff block | `--happy-code-background` |
|
|
758
|
+
| A hovered row | `--color-background-ghost` |
|
|
759
|
+
| A selected row or active tab | `--happy-selected-background` |
|
|
760
|
+
| A pressed control | `--color-background-tertiary` |
|
|
761
|
+
| A text input | `--happy-input-background` |
|
|
762
|
+
|
|
763
|
+
Default to the first row. A plugin app on a workspace page sits flush against
|
|
764
|
+
Happy's own `--surface`; painting `--color-background-primary` makes the seam
|
|
765
|
+
invisible, which is correct. Reach for `--happy-canvas` only when you genuinely
|
|
766
|
+
have separated cards.
|
|
767
|
+
|
|
768
|
+
### 7.2 Borders
|
|
769
|
+
|
|
770
|
+
One hairline, everywhere: `1px solid var(--color-border-primary)`.
|
|
771
|
+
|
|
772
|
+
- Prefer a border over a shadow to separate things. Happy is a flat, quiet system.
|
|
773
|
+
- A card gets a border **or** `--shadow-sm`, never both.
|
|
774
|
+
- Between list rows use `border-bottom` on each row except the last, or better, a
|
|
775
|
+
flex `gap` with no border at all.
|
|
776
|
+
- To reserve space for a border that appears on hover or selection, use
|
|
777
|
+
`--color-border-ghost` (transparent) rather than a margin that shifts on hover.
|
|
778
|
+
|
|
779
|
+
### 7.3 Radii
|
|
780
|
+
|
|
781
|
+
| Element | Radius |
|
|
782
|
+
| -------------------------------------------- | ------------------------- |
|
|
783
|
+
| Button, input, select, menu item, small chip | `--border-radius-sm` (6) |
|
|
784
|
+
| List row, content block, code well | `--border-radius-md` (8) |
|
|
785
|
+
| Card | `--border-radius-lg` (10) |
|
|
786
|
+
| A large panel or in-app dialog | `--border-radius-xl` (14) |
|
|
787
|
+
| Pill, avatar, progress track | `--border-radius-full` |
|
|
788
|
+
|
|
789
|
+
**Nested corners must be true parallel curves.** When a child's edge sits in a
|
|
790
|
+
rounded corner of its parent:
|
|
791
|
+
|
|
792
|
+
```
|
|
793
|
+
inner radius = max(0, outer radius − inset)
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
`inset` is the real distance between the two border-box edges — the parent's
|
|
797
|
+
border plus its padding. A 10 px card with a 1 px border and 12 px padding gives
|
|
798
|
+
a child sitting in the corner a radius of `max(0, 10 − 13) = 0`. A header strip
|
|
799
|
+
flush inside that card (1 px inset, no padding) gets `10 − 1 = 9 px`.
|
|
800
|
+
|
|
801
|
+
If the horizontal and vertical insets differ, do not compromise — change the
|
|
802
|
+
layout so they match.
|
|
803
|
+
|
|
804
|
+
### 7.4 Shadows
|
|
805
|
+
|
|
806
|
+
Use a shadow only for something that genuinely floats above the page:
|
|
807
|
+
|
|
808
|
+
| Thing | Shadow |
|
|
809
|
+
| ---------------------------- | ---------------------- |
|
|
810
|
+
| A card resting on the canvas | none, or `--shadow-sm` |
|
|
811
|
+
| A dropdown, popover, tooltip | `--shadow-md` |
|
|
812
|
+
| An in-app dialog you own | `--shadow-lg` |
|
|
813
|
+
|
|
814
|
+
Never put a shadow on a button, input, row, chip, badge, or table. Never write a
|
|
815
|
+
shadow with your own colour; if you must compose one, tint it with
|
|
816
|
+
`--happy-shadow-color`.
|
|
817
|
+
|
|
818
|
+
---
|
|
819
|
+
|
|
820
|
+
## 8. Density and rhythm
|
|
821
|
+
|
|
822
|
+
Happy is an information-dense desktop tool, not a marketing page.
|
|
823
|
+
|
|
824
|
+
| Element | Height |
|
|
825
|
+
| --------------------------------------------------- | --------- |
|
|
826
|
+
| Small button / chip / compact control | **28 px** |
|
|
827
|
+
| Default button, input, select | **36 px** |
|
|
828
|
+
| Large button (a page's main action) | **44 px** |
|
|
829
|
+
| Compact list row (one line) | **32 px** |
|
|
830
|
+
| Standard list row (title + caption) | **44 px** |
|
|
831
|
+
| List row with a ≥ 20 px glyph, avatar, or thumbnail | **56 px** |
|
|
832
|
+
| Table header row | **32 px** |
|
|
833
|
+
| Table body row | **36 px** |
|
|
834
|
+
| Your own content toolbar | **48 px** |
|
|
835
|
+
| Your own content header strip | **56 px** |
|
|
836
|
+
|
|
837
|
+
Additional rules:
|
|
838
|
+
|
|
839
|
+
- A vertical list of rows uses `gap: 0` with hairline separators, or `gap: 8px`
|
|
840
|
+
with bordered rows. Not both.
|
|
841
|
+
- Never exceed 24 px of vertical space between a heading and its content.
|
|
842
|
+
- Never centre a whole page's content vertically, except an empty state (§11.1).
|
|
843
|
+
- Never use a hero band, a full-bleed illustration, or a decorative gradient.
|
|
844
|
+
- Icons in rows and buttons are **16 px**; in a page title, **20 px**; in an empty
|
|
845
|
+
state, **32 px**. Nothing else.
|
|
846
|
+
|
|
847
|
+
---
|
|
848
|
+
|
|
849
|
+
## 9. Components
|
|
850
|
+
|
|
851
|
+
Build each of these from plain HTML plus the variables. Do not add a component
|
|
852
|
+
library, a CSS framework, or a design-token package.
|
|
853
|
+
|
|
854
|
+
### 9.1 Buttons
|
|
855
|
+
|
|
856
|
+
Three variants, plus one destructive modifier of Secondary:
|
|
857
|
+
|
|
858
|
+
| Variant | Fill | Text | Border | When |
|
|
859
|
+
| --------- | ---------------------------- | ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------ |
|
|
860
|
+
| Primary | `--color-background-inverse` | `--color-text-inverse` | none | One per view, the main action |
|
|
861
|
+
| Secondary | `--color-background-primary` | `--color-text-primary` | `1px solid --color-border-primary` | Everything else |
|
|
862
|
+
| Ghost | `transparent` | `--color-text-secondary` | none | Toolbar and row-level actions |
|
|
863
|
+
| Danger | `transparent` | `--color-text-danger` | `1px solid --color-border-danger` | Only the confirming button of a destructive action, and never more than one per view |
|
|
864
|
+
|
|
865
|
+
A destructive action is never the Primary variant — a black filled button is the
|
|
866
|
+
safe default action, and the delete must not be the thing the eye lands on first.
|
|
867
|
+
|
|
868
|
+
```css
|
|
869
|
+
.btn {
|
|
870
|
+
display: inline-flex;
|
|
871
|
+
align-items: center;
|
|
872
|
+
justify-content: center;
|
|
873
|
+
gap: 6px;
|
|
874
|
+
height: 36px;
|
|
875
|
+
padding: 0 14px;
|
|
876
|
+
flex: none;
|
|
877
|
+
font: inherit;
|
|
878
|
+
font-size: 14px;
|
|
879
|
+
line-height: 20px;
|
|
880
|
+
font-weight: 500;
|
|
881
|
+
border: 1px solid transparent;
|
|
882
|
+
border-radius: var(--app-radius-control);
|
|
883
|
+
background: transparent;
|
|
884
|
+
color: var(--app-text);
|
|
885
|
+
cursor: pointer;
|
|
886
|
+
user-select: none;
|
|
887
|
+
transition:
|
|
888
|
+
background-color 120ms ease,
|
|
889
|
+
border-color 120ms ease;
|
|
890
|
+
}
|
|
891
|
+
.btn:disabled {
|
|
892
|
+
cursor: default;
|
|
893
|
+
background: var(--color-background-disabled);
|
|
894
|
+
color: var(--color-text-disabled);
|
|
895
|
+
border-color: transparent;
|
|
896
|
+
}
|
|
897
|
+
.btn:focus-visible {
|
|
898
|
+
outline: 2px solid var(--app-accent);
|
|
899
|
+
outline-offset: 2px;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
.btn-primary {
|
|
903
|
+
background: var(--color-background-inverse);
|
|
904
|
+
color: var(--color-text-inverse);
|
|
905
|
+
}
|
|
906
|
+
.btn-secondary {
|
|
907
|
+
background: var(--app-surface);
|
|
908
|
+
border-color: var(--app-border);
|
|
909
|
+
}
|
|
910
|
+
.btn-secondary:hover:not(:disabled) {
|
|
911
|
+
background: var(--color-background-ghost);
|
|
912
|
+
}
|
|
913
|
+
.btn-ghost {
|
|
914
|
+
color: var(--app-muted);
|
|
915
|
+
padding: 0 10px;
|
|
916
|
+
}
|
|
917
|
+
.btn-ghost:hover:not(:disabled) {
|
|
918
|
+
background: var(--color-background-ghost);
|
|
919
|
+
color: var(--app-text);
|
|
920
|
+
}
|
|
921
|
+
.btn-danger {
|
|
922
|
+
background: transparent;
|
|
923
|
+
color: var(--color-text-danger);
|
|
924
|
+
border-color: var(--color-border-danger);
|
|
925
|
+
}
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
- Small button: `height: 28px; padding: 0 10px; font-size: 12px;`.
|
|
929
|
+
- Large button: `height: 44px; padding: 0 20px; font-size: 15px;`.
|
|
930
|
+
- An icon-only button is square: 28×28 or 36×36, and **must** have `aria-label`.
|
|
931
|
+
- Button labels are sentence case, verb first: "Add item", not "ADD ITEM" or
|
|
932
|
+
"Add Item".
|
|
933
|
+
- Never a gradient fill, never a coloured shadow, never a full-width button
|
|
934
|
+
unless the container is narrower than 320 px.
|
|
935
|
+
|
|
936
|
+
### 9.2 Inputs and forms
|
|
937
|
+
|
|
938
|
+
```css
|
|
939
|
+
.field {
|
|
940
|
+
display: flex;
|
|
941
|
+
flex-direction: column;
|
|
942
|
+
gap: 4px;
|
|
943
|
+
}
|
|
944
|
+
.field-label {
|
|
945
|
+
font-size: 12px;
|
|
946
|
+
line-height: 18px;
|
|
947
|
+
font-weight: 500;
|
|
948
|
+
color: var(--app-muted);
|
|
949
|
+
}
|
|
950
|
+
.input {
|
|
951
|
+
height: 36px;
|
|
952
|
+
padding: 0 10px;
|
|
953
|
+
width: 100%;
|
|
954
|
+
font: inherit;
|
|
955
|
+
font-size: 14px;
|
|
956
|
+
line-height: 20px;
|
|
957
|
+
color: var(--happy-input-text, var(--app-text));
|
|
958
|
+
background: var(--happy-input-background, var(--app-raised));
|
|
959
|
+
border: 1px solid var(--app-border);
|
|
960
|
+
border-radius: var(--app-radius-control);
|
|
961
|
+
}
|
|
962
|
+
.input::placeholder {
|
|
963
|
+
color: var(--happy-input-placeholder, var(--app-muted));
|
|
964
|
+
}
|
|
965
|
+
/* A field keeps its border when focused and draws the ring just inside its own
|
|
966
|
+
edge, so there is no 3px halo gap between the two. Buttons, which have no
|
|
967
|
+
filled edge to preserve, use the outward 2px offset instead. */
|
|
968
|
+
.input:focus-visible {
|
|
969
|
+
outline: 2px solid var(--app-accent);
|
|
970
|
+
outline-offset: -1px;
|
|
971
|
+
}
|
|
972
|
+
.input[aria-invalid="true"] {
|
|
973
|
+
border-color: var(--color-border-danger);
|
|
974
|
+
}
|
|
975
|
+
textarea.input {
|
|
976
|
+
height: auto;
|
|
977
|
+
min-height: 72px;
|
|
978
|
+
padding: 8px 10px;
|
|
979
|
+
resize: vertical;
|
|
980
|
+
}
|
|
981
|
+
```
|
|
982
|
+
|
|
983
|
+
- Labels go **above** the field, 6 px away. Never use a placeholder as the label.
|
|
984
|
+
- **Exception, and only this one:** a search or filter field inside a 48 px
|
|
985
|
+
toolbar has no room for a label above it, so its name is given by
|
|
986
|
+
`aria-label` and its placeholder shows the same words. This is the single
|
|
987
|
+
place a placeholder-only field is permitted; every field in a form body has a
|
|
988
|
+
visible label.
|
|
989
|
+
- A form is a flex column with `gap: 16px` between fields.
|
|
990
|
+
- Error text sits 4 px under the field, 12/18 px, `--color-text-danger`, and is
|
|
991
|
+
referenced by `aria-describedby`.
|
|
992
|
+
- Actions go in a row at the end: `justify-content: flex-end; gap: 8px`, primary
|
|
993
|
+
last (rightmost).
|
|
994
|
+
- Never use a native `<input type="color">`, `<input type="range">` styling hack,
|
|
995
|
+
or a custom checkbox that loses keyboard behaviour. Style the native control
|
|
996
|
+
with `accent-color: var(--app-accent)`.
|
|
997
|
+
|
|
998
|
+
### 9.3 Lists
|
|
999
|
+
|
|
1000
|
+
The standard row: a leading glyph or nothing, a shrinking main column, a fixed
|
|
1001
|
+
trailing column.
|
|
1002
|
+
|
|
1003
|
+
```css
|
|
1004
|
+
.list {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
flex-direction: column;
|
|
1007
|
+
}
|
|
1008
|
+
.row {
|
|
1009
|
+
display: flex;
|
|
1010
|
+
align-items: center;
|
|
1011
|
+
gap: 12px;
|
|
1012
|
+
min-height: 44px;
|
|
1013
|
+
padding: 8px 12px;
|
|
1014
|
+
border-bottom: 1px solid var(--app-border);
|
|
1015
|
+
background: var(--app-surface);
|
|
1016
|
+
}
|
|
1017
|
+
.list > .row:last-child {
|
|
1018
|
+
border-bottom: none;
|
|
1019
|
+
}
|
|
1020
|
+
.row:hover {
|
|
1021
|
+
background: var(--color-background-ghost);
|
|
1022
|
+
}
|
|
1023
|
+
.row[aria-selected="true"] {
|
|
1024
|
+
background: var(--happy-selected-background, var(--color-background-tertiary));
|
|
1025
|
+
}
|
|
1026
|
+
.row-main {
|
|
1027
|
+
display: flex;
|
|
1028
|
+
flex-direction: column;
|
|
1029
|
+
/* No gap: the 20 px and 16 px line boxes already separate the two lines, and
|
|
1030
|
+
a two-line row measures 52 px. */
|
|
1031
|
+
flex: 1 1 auto;
|
|
1032
|
+
min-width: 0;
|
|
1033
|
+
}
|
|
1034
|
+
.row-title {
|
|
1035
|
+
font-size: 14px;
|
|
1036
|
+
line-height: 20px;
|
|
1037
|
+
font-weight: 500;
|
|
1038
|
+
overflow: hidden;
|
|
1039
|
+
text-overflow: ellipsis;
|
|
1040
|
+
white-space: nowrap;
|
|
1041
|
+
}
|
|
1042
|
+
.row-meta {
|
|
1043
|
+
font-size: 12px;
|
|
1044
|
+
line-height: 18px;
|
|
1045
|
+
color: var(--app-muted);
|
|
1046
|
+
}
|
|
1047
|
+
.row-aside {
|
|
1048
|
+
display: flex;
|
|
1049
|
+
align-items: center;
|
|
1050
|
+
gap: 8px;
|
|
1051
|
+
flex: none;
|
|
1052
|
+
}
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
- A whole row that is clickable must be a `<button>` or an `<a>`, or carry
|
|
1056
|
+
`role="button"` **and** `tabindex="0"` **and** an Enter/Space handler.
|
|
1057
|
+
- Do not nest an interactive control inside a clickable row without stopping
|
|
1058
|
+
propagation; better, make the row non-clickable and give it an explicit action.
|
|
1059
|
+
- Reorderable lists need stable keys; never key by array index.
|
|
1060
|
+
- A list that can hold thousands of entries must be virtualised.
|
|
1061
|
+
|
|
1062
|
+
### 9.4 Tables
|
|
1063
|
+
|
|
1064
|
+
A table is the one legitimate place for CSS Grid, or for a real `<table>`.
|
|
1065
|
+
Prefer `<table>` — you get semantics and keyboard behaviour for free.
|
|
1066
|
+
|
|
1067
|
+
```css
|
|
1068
|
+
.table {
|
|
1069
|
+
width: 100%;
|
|
1070
|
+
border-collapse: collapse;
|
|
1071
|
+
font-size: 14px;
|
|
1072
|
+
line-height: 20px;
|
|
1073
|
+
}
|
|
1074
|
+
.table thead th {
|
|
1075
|
+
height: 32px;
|
|
1076
|
+
padding: 0 12px;
|
|
1077
|
+
text-align: left;
|
|
1078
|
+
font-size: 11px;
|
|
1079
|
+
line-height: 16px;
|
|
1080
|
+
font-weight: 500;
|
|
1081
|
+
color: var(--color-text-tertiary);
|
|
1082
|
+
background: var(--app-raised);
|
|
1083
|
+
border-bottom: 1px solid var(--app-border);
|
|
1084
|
+
}
|
|
1085
|
+
.table tbody td {
|
|
1086
|
+
height: 36px;
|
|
1087
|
+
padding: 0 12px;
|
|
1088
|
+
border-bottom: 1px solid var(--app-border);
|
|
1089
|
+
}
|
|
1090
|
+
.table tbody tr:hover td {
|
|
1091
|
+
background: var(--color-background-ghost);
|
|
1092
|
+
}
|
|
1093
|
+
.table td.num {
|
|
1094
|
+
text-align: right;
|
|
1095
|
+
font-variant-numeric: tabular-nums;
|
|
1096
|
+
/* A truncated number is a wrong number. Numeric cells never ellipsize; give
|
|
1097
|
+
the column enough width instead, or drop it entirely (§4.5). */
|
|
1098
|
+
white-space: nowrap;
|
|
1099
|
+
}
|
|
1100
|
+
/* An action cell holds a control, not text, so it is sized to that control and
|
|
1101
|
+
drops the text padding: 28px button + 8px of breathing room on each side. */
|
|
1102
|
+
.table .actions-column {
|
|
1103
|
+
width: 44px;
|
|
1104
|
+
}
|
|
1105
|
+
.table td.actions {
|
|
1106
|
+
padding: 0 8px;
|
|
1107
|
+
text-align: right;
|
|
1108
|
+
}
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
- Never zebra-stripe. A hairline between rows is enough.
|
|
1112
|
+
- Column heads are Micro type (11/16, weight 500, tertiary), left aligned, except
|
|
1113
|
+
numeric columns which are right aligned along with their cells.
|
|
1114
|
+
- A sortable head is a `<button>` inside the `<th>` and sets `aria-sort`.
|
|
1115
|
+
- Below 640 px, hide non-essential columns; do not turn rows into cards. Never
|
|
1116
|
+
hide the numeric column by squeezing it — hide it outright or keep it whole.
|
|
1117
|
+
- **Size every fixed column to its content, including its own padding.** With
|
|
1118
|
+
`table-layout: fixed` a declared width that is narrower than the cell's content
|
|
1119
|
+
plus its padding does not grow the column — the content spills past the table's
|
|
1120
|
+
edge and is clipped by whatever contains it, which is easy to miss because the
|
|
1121
|
+
document itself never reports an overflow. An icon-button column is 44 px wide
|
|
1122
|
+
_and_ uses the 8 px padding above; the two numbers go together.
|
|
1123
|
+
- A table inside a card with a radius needs `overflow: hidden` on the card, so the
|
|
1124
|
+
first header cell and last row cell are clipped to the curve. The header's own
|
|
1125
|
+
corners then need no radius of their own.
|
|
1126
|
+
- **A sticky header is optional, and only works in one arrangement.**
|
|
1127
|
+
`position: sticky` on `thead th` sticks to the nearest scrolling ancestor, so it
|
|
1128
|
+
is inert when the page scrollport is further up the tree, and any
|
|
1129
|
+
`overflow: hidden` or `overflow-x: auto` between the two — including the card
|
|
1130
|
+
clip above — becomes that ancestor and breaks it. Choose one:
|
|
1131
|
+
|
|
1132
|
+
- **Simplest, and the default:** no sticky header. The table scrolls with the
|
|
1133
|
+
page. This is correct and is what most plugin apps should do.
|
|
1134
|
+
- **Sticky:** the table's own container owns the vertical scroll
|
|
1135
|
+
(`overflow-y: auto` with a bounded height) and there is no clipping ancestor
|
|
1136
|
+
between it and the `thead`. Then add `position: sticky; top: 0; z-index: 1`
|
|
1137
|
+
and a solid `background` on the header cells. Do not also put the table in a
|
|
1138
|
+
rounded, clipped card, and remember §4.4: this container is now the view's one
|
|
1139
|
+
vertical scrollport, so the page around it must not scroll too.
|
|
1140
|
+
|
|
1141
|
+
### 9.5 Cards
|
|
1142
|
+
|
|
1143
|
+
```css
|
|
1144
|
+
.card {
|
|
1145
|
+
display: flex;
|
|
1146
|
+
flex-direction: column;
|
|
1147
|
+
gap: 12px;
|
|
1148
|
+
padding: 16px;
|
|
1149
|
+
background: var(--app-surface);
|
|
1150
|
+
border: 1px solid var(--app-border);
|
|
1151
|
+
border-radius: var(--app-radius-card);
|
|
1152
|
+
}
|
|
1153
|
+
.card-title {
|
|
1154
|
+
font-size: 15px;
|
|
1155
|
+
line-height: 20px;
|
|
1156
|
+
font-weight: 600;
|
|
1157
|
+
}
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
A card grid is a flex row with `flex-wrap: wrap; gap: 12px` and children at
|
|
1161
|
+
`flex: 1 1 280px`, or a real Grid with `repeat(auto-fill, minmax(280px, 1fr))`
|
|
1162
|
+
(comment the Grid, §4.1).
|
|
1163
|
+
|
|
1164
|
+
### 9.6 Your own navigation, toolbars, and tabs
|
|
1165
|
+
|
|
1166
|
+
Permitted, for navigating **your** content:
|
|
1167
|
+
|
|
1168
|
+
```css
|
|
1169
|
+
.toolbar {
|
|
1170
|
+
display: flex;
|
|
1171
|
+
align-items: center;
|
|
1172
|
+
gap: 8px;
|
|
1173
|
+
height: 48px;
|
|
1174
|
+
flex: none;
|
|
1175
|
+
/* Matches the page gutter (§2.5), so the toolbar's contents line up with the
|
|
1176
|
+
content below it. Raise both to 24px together on a wide page. */
|
|
1177
|
+
padding: 0 16px;
|
|
1178
|
+
border-bottom: 1px solid var(--app-border);
|
|
1179
|
+
background: var(--app-surface);
|
|
1180
|
+
}
|
|
1181
|
+
.tabs {
|
|
1182
|
+
display: flex;
|
|
1183
|
+
align-items: center;
|
|
1184
|
+
gap: 4px;
|
|
1185
|
+
}
|
|
1186
|
+
.tab {
|
|
1187
|
+
height: 28px;
|
|
1188
|
+
padding: 0 10px;
|
|
1189
|
+
border-radius: var(--app-radius-control);
|
|
1190
|
+
font-size: 13px;
|
|
1191
|
+
line-height: 18px;
|
|
1192
|
+
font-weight: 500;
|
|
1193
|
+
color: var(--app-muted);
|
|
1194
|
+
background: transparent;
|
|
1195
|
+
border: none;
|
|
1196
|
+
cursor: pointer;
|
|
1197
|
+
}
|
|
1198
|
+
.tab[aria-selected="true"] {
|
|
1199
|
+
color: var(--app-text);
|
|
1200
|
+
background: var(--happy-selected-background, var(--color-background-tertiary));
|
|
1201
|
+
}
|
|
1202
|
+
.tab:focus-visible {
|
|
1203
|
+
outline: 2px solid var(--app-accent);
|
|
1204
|
+
outline-offset: 2px;
|
|
1205
|
+
}
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
- Tabs use `role="tablist"` / `role="tab"` / `role="tabpanel"` and arrow-key
|
|
1209
|
+
navigation.
|
|
1210
|
+
- A tab strip is horizontal only. No vertical tab rail — that reads as Happy
|
|
1211
|
+
chrome (§1.4).
|
|
1212
|
+
- Breadcrumbs: 12/18 px, `--color-text-secondary`, separated by a `/` in
|
|
1213
|
+
`--color-text-tertiary`, last crumb `--color-text-primary`.
|
|
1214
|
+
|
|
1215
|
+
### 9.7 Status: badges, chips, and indicators
|
|
1216
|
+
|
|
1217
|
+
```css
|
|
1218
|
+
.badge {
|
|
1219
|
+
display: inline-flex;
|
|
1220
|
+
align-items: center;
|
|
1221
|
+
gap: 4px;
|
|
1222
|
+
flex: none;
|
|
1223
|
+
height: 20px;
|
|
1224
|
+
padding: 0 8px;
|
|
1225
|
+
font-size: 11px;
|
|
1226
|
+
line-height: 16px;
|
|
1227
|
+
font-weight: 500;
|
|
1228
|
+
border-radius: var(--border-radius-full, 999px);
|
|
1229
|
+
background: var(--color-background-secondary);
|
|
1230
|
+
color: var(--color-text-secondary);
|
|
1231
|
+
}
|
|
1232
|
+
.badge-info {
|
|
1233
|
+
background: var(--color-background-info);
|
|
1234
|
+
color: var(--color-text-info);
|
|
1235
|
+
}
|
|
1236
|
+
.badge-success {
|
|
1237
|
+
background: var(--color-background-success);
|
|
1238
|
+
color: var(--color-text-success);
|
|
1239
|
+
}
|
|
1240
|
+
.badge-warning {
|
|
1241
|
+
background: var(--color-background-warning);
|
|
1242
|
+
color: var(--color-text-warning);
|
|
1243
|
+
}
|
|
1244
|
+
.badge-danger {
|
|
1245
|
+
background: var(--color-background-danger);
|
|
1246
|
+
color: var(--color-text-danger);
|
|
1247
|
+
}
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
Semantics are fixed: **green = succeeded / healthy / connected**, **orange =
|
|
1251
|
+
needs attention / degraded / unsaved**, **red = failed / destructive / offline**,
|
|
1252
|
+
**blue = in progress / informational**, **neutral grey = idle / unknown /
|
|
1253
|
+
disabled**. Never use a semantic colour decoratively, and never re-map one.
|
|
1254
|
+
|
|
1255
|
+
A status must be legible without colour: pair the fill with a word ("Failed") or
|
|
1256
|
+
a text glyph. Do not use a bare coloured dot as the only signal.
|
|
1257
|
+
|
|
1258
|
+
### 9.8 Menus and dialogs you own
|
|
1259
|
+
|
|
1260
|
+
**When to own one.** An overflow menu on a row, a filter popover, a confirm
|
|
1261
|
+
prompt for a destructive action: these belong inside your document. A whole view
|
|
1262
|
+
that should _be_ a dialog is different — ask Happy to present it (surface 3) via
|
|
1263
|
+
`app.requestDisplayMode`, and never fake it by covering your own viewport with a
|
|
1264
|
+
scrim.
|
|
1265
|
+
|
|
1266
|
+
**Menu / popover.** Anchored to its trigger, never wider than 280 px:
|
|
1267
|
+
|
|
1268
|
+
```css
|
|
1269
|
+
.menu {
|
|
1270
|
+
position: absolute;
|
|
1271
|
+
z-index: 10;
|
|
1272
|
+
min-width: 180px;
|
|
1273
|
+
max-width: 280px;
|
|
1274
|
+
display: flex;
|
|
1275
|
+
flex-direction: column;
|
|
1276
|
+
padding: 4px;
|
|
1277
|
+
background: var(--app-surface);
|
|
1278
|
+
border: 1px solid var(--app-border);
|
|
1279
|
+
border-radius: var(--app-radius-block);
|
|
1280
|
+
box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.24));
|
|
1281
|
+
}
|
|
1282
|
+
.menu-item {
|
|
1283
|
+
display: flex;
|
|
1284
|
+
align-items: center;
|
|
1285
|
+
gap: 8px;
|
|
1286
|
+
height: 28px;
|
|
1287
|
+
padding: 0 8px;
|
|
1288
|
+
flex: none;
|
|
1289
|
+
font: inherit;
|
|
1290
|
+
font-size: 13px;
|
|
1291
|
+
line-height: 18px;
|
|
1292
|
+
text-align: left;
|
|
1293
|
+
background: none;
|
|
1294
|
+
border: 0;
|
|
1295
|
+
border-radius: var(--app-radius-control);
|
|
1296
|
+
color: var(--app-text);
|
|
1297
|
+
cursor: pointer;
|
|
1298
|
+
}
|
|
1299
|
+
.menu-item:hover {
|
|
1300
|
+
background: var(--app-ghost);
|
|
1301
|
+
}
|
|
1302
|
+
.menu-item[data-destructive] {
|
|
1303
|
+
color: var(--app-danger);
|
|
1304
|
+
}
|
|
1305
|
+
.menu-separator {
|
|
1306
|
+
height: 1px;
|
|
1307
|
+
margin: 4px 0;
|
|
1308
|
+
background: var(--app-border);
|
|
1309
|
+
flex: none;
|
|
1310
|
+
}
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
The trigger is a `<button>` with `aria-haspopup="menu"` and `aria-expanded`. The
|
|
1314
|
+
menu is `role="menu"`, its items `role="menuitem"`. Arrow keys move, Enter
|
|
1315
|
+
activates, Escape closes and returns focus to the trigger, and a click anywhere
|
|
1316
|
+
else closes it.
|
|
1317
|
+
|
|
1318
|
+
**Where to put it.** `position: absolute` inside a `position: relative` parent is
|
|
1319
|
+
correct _only_ when no ancestor clips or scrolls. A menu opened from a table row
|
|
1320
|
+
or a list inside a scrollport is the common case, and there the absolute menu is
|
|
1321
|
+
clipped by the scrollport and drifts out of alignment as the user scrolls. In that
|
|
1322
|
+
case anchor it to the viewport instead:
|
|
1323
|
+
|
|
1324
|
+
- `position: fixed`, with `top`/`left` computed from the trigger's
|
|
1325
|
+
`getBoundingClientRect()` at open time.
|
|
1326
|
+
- Flip above the trigger when it would extend past the bottom edge.
|
|
1327
|
+
- Close it on scroll and on window resize rather than trying to follow the
|
|
1328
|
+
trigger.
|
|
1329
|
+
|
|
1330
|
+
This is the one place a plugin app reads geometry from the DOM, and it is
|
|
1331
|
+
legitimate: there is no CSS expression of "anchored to that element but not
|
|
1332
|
+
clipped by its scroll container" that is portable today.
|
|
1333
|
+
|
|
1334
|
+
**Confirm dialog.** 360 px wide, centred over a scrim, for one destructive
|
|
1335
|
+
decision:
|
|
1336
|
+
|
|
1337
|
+
```css
|
|
1338
|
+
.scrim {
|
|
1339
|
+
position: fixed;
|
|
1340
|
+
inset: 0;
|
|
1341
|
+
z-index: 20;
|
|
1342
|
+
display: flex;
|
|
1343
|
+
align-items: center;
|
|
1344
|
+
justify-content: center;
|
|
1345
|
+
padding: 24px;
|
|
1346
|
+
background: var(--happy-scrim, rgb(0 0 0 / 0.48));
|
|
1347
|
+
}
|
|
1348
|
+
.dialog {
|
|
1349
|
+
display: flex;
|
|
1350
|
+
flex-direction: column;
|
|
1351
|
+
gap: 16px;
|
|
1352
|
+
width: 360px;
|
|
1353
|
+
max-width: 100%;
|
|
1354
|
+
padding: 16px;
|
|
1355
|
+
background: var(--app-surface);
|
|
1356
|
+
border: 1px solid var(--app-border);
|
|
1357
|
+
/* 14px shell corner; the 1px border plus 16px padding puts every child
|
|
1358
|
+
outside the corner field, so no child needs an inner radius (§7.3). */
|
|
1359
|
+
border-radius: var(--border-radius-xl, 14px);
|
|
1360
|
+
box-shadow: var(--shadow-lg, 0 12px 32px rgb(0 0 0 / 0.45));
|
|
1361
|
+
}
|
|
1362
|
+
.dialog-title {
|
|
1363
|
+
font-size: 17px;
|
|
1364
|
+
line-height: 22px;
|
|
1365
|
+
font-weight: 600;
|
|
1366
|
+
margin: 0;
|
|
1367
|
+
}
|
|
1368
|
+
.dialog-body {
|
|
1369
|
+
font-size: 14px;
|
|
1370
|
+
line-height: 20px;
|
|
1371
|
+
color: var(--app-muted);
|
|
1372
|
+
margin: 0;
|
|
1373
|
+
}
|
|
1374
|
+
.dialog-actions {
|
|
1375
|
+
display: flex;
|
|
1376
|
+
justify-content: flex-end;
|
|
1377
|
+
gap: 8px;
|
|
1378
|
+
}
|
|
1379
|
+
```
|
|
1380
|
+
|
|
1381
|
+
Use `<dialog>` if you can — you get the focus trap, Escape, and the accessible
|
|
1382
|
+
role for free. Otherwise: `role="dialog"`, `aria-modal="true"`,
|
|
1383
|
+
`aria-labelledby` pointing at the title, focus moved into the dialog on open and
|
|
1384
|
+
back to the trigger on close, Escape cancels, and a scrim click cancels unless
|
|
1385
|
+
the dialog holds unsaved input. The confirming button is the Danger variant
|
|
1386
|
+
(§9.1) and sits rightmost; Cancel is Secondary and sits to its left.
|
|
1387
|
+
|
|
1388
|
+
One dialog at a time. Never stack two, and never open one from inside a menu
|
|
1389
|
+
without closing the menu first.
|
|
1390
|
+
|
|
1391
|
+
### 9.9 Links
|
|
1392
|
+
|
|
1393
|
+
```css
|
|
1394
|
+
a {
|
|
1395
|
+
color: var(--happy-link, var(--app-accent));
|
|
1396
|
+
text-decoration: none;
|
|
1397
|
+
}
|
|
1398
|
+
a:hover {
|
|
1399
|
+
text-decoration: underline;
|
|
1400
|
+
}
|
|
1401
|
+
a:focus-visible {
|
|
1402
|
+
outline: 2px solid var(--app-accent);
|
|
1403
|
+
outline-offset: 2px;
|
|
1404
|
+
border-radius: 2px;
|
|
1405
|
+
}
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
An external link must be opened through the host, not by the iframe — and only
|
|
1409
|
+
when the host offers it. `openLinks` is an optional host capability: the message
|
|
1410
|
+
and page hosts advertise it, the standalone Happy Agent plugin page does not. Feature
|
|
1411
|
+
detect it, and when it is absent render the URL as selectable text rather than a
|
|
1412
|
+
link that does nothing:
|
|
1413
|
+
|
|
1414
|
+
```tsx
|
|
1415
|
+
const canOpenLinks = app?.getHostCapabilities()?.openLinks !== undefined;
|
|
1416
|
+
|
|
1417
|
+
return canOpenLinks ? (
|
|
1418
|
+
<a
|
|
1419
|
+
href={url}
|
|
1420
|
+
onClick={(event) => {
|
|
1421
|
+
event.preventDefault();
|
|
1422
|
+
void app!.openLink({ url });
|
|
1423
|
+
}}
|
|
1424
|
+
>
|
|
1425
|
+
{label}
|
|
1426
|
+
</a>
|
|
1427
|
+
) : (
|
|
1428
|
+
<span className="mono wrap-anywhere">{url}</span>
|
|
1429
|
+
);
|
|
1430
|
+
```
|
|
1431
|
+
|
|
1432
|
+
Never `window.open`, never `target="_blank"`, never `location.href = …`. A
|
|
1433
|
+
sandboxed frame cannot navigate the top level and the attempt fails silently.
|
|
1434
|
+
|
|
1435
|
+
---
|
|
1436
|
+
|
|
1437
|
+
## 10. Using the accent meaningfully
|
|
1438
|
+
|
|
1439
|
+
There is **one** accent: `--color-ring-primary` (system blue), plus
|
|
1440
|
+
`--happy-link` (Happy teal) for hyperlink text.
|
|
1441
|
+
|
|
1442
|
+
Use the accent only for:
|
|
1443
|
+
|
|
1444
|
+
1. The focus ring on every focusable element.
|
|
1445
|
+
2. The selected state of a **form control** — a radio, checkbox, or switch — which
|
|
1446
|
+
means setting `accent-color: var(--color-ring-primary)` and letting the native
|
|
1447
|
+
control paint itself.
|
|
1448
|
+
3. An in-progress indicator, and the `--color-text-info` badge (§9.7).
|
|
1449
|
+
4. `--color-text-info` for informational text and icons.
|
|
1450
|
+
|
|
1451
|
+
A **selected row or tab is neutral**, not accent: it takes
|
|
1452
|
+
`--happy-selected-background` and normal text (§9.3, §9.6). This is deliberate —
|
|
1453
|
+
a table with six accent-filled rows is unreadable, and the neutral fill is what
|
|
1454
|
+
Happy itself uses. Do not add an accent underline, left rule, or text colour to a
|
|
1455
|
+
selected row or tab on top of the neutral fill.
|
|
1456
|
+
|
|
1457
|
+
That list is exhaustive. Do **not** use the accent for: page or section headings,
|
|
1458
|
+
body text, card borders, row hover, the primary button (that is black —
|
|
1459
|
+
`--color-background-inverse`), any filled area larger than a chip, or decoration.
|
|
1460
|
+
|
|
1461
|
+
One deliberate difference to be aware of: Happy's own chrome uses teal
|
|
1462
|
+
(`--happy-link`) for its general interactive colour, while a plugin app's
|
|
1463
|
+
selection and focus read system blue. That is intentional — the blue is the
|
|
1464
|
+
platform's focus colour and the standard MCP Apps `ring-primary` — but it means
|
|
1465
|
+
you should use teal, not blue, for anything of yours that will sit directly
|
|
1466
|
+
beside Happy's chrome: hyperlink text (§9.8) and the loading arc (§11.2).
|
|
1467
|
+
|
|
1468
|
+
---
|
|
1469
|
+
|
|
1470
|
+
## 11. States
|
|
1471
|
+
|
|
1472
|
+
Every view that loads data has four states, and you must implement all four.
|
|
1473
|
+
|
|
1474
|
+
### 11.1 Empty
|
|
1475
|
+
|
|
1476
|
+
Centred in the available space, at most 320 px wide, and — as the one exception
|
|
1477
|
+
to §6.2's rule against centred text — centre aligned:
|
|
1478
|
+
|
|
1479
|
+
```css
|
|
1480
|
+
.empty {
|
|
1481
|
+
display: flex;
|
|
1482
|
+
flex-direction: column;
|
|
1483
|
+
align-items: center;
|
|
1484
|
+
justify-content: center;
|
|
1485
|
+
gap: 8px;
|
|
1486
|
+
flex: 1 1 auto;
|
|
1487
|
+
padding: 32px;
|
|
1488
|
+
max-width: 320px;
|
|
1489
|
+
margin-inline: auto;
|
|
1490
|
+
text-align: center;
|
|
1491
|
+
}
|
|
1492
|
+
.empty-glyph {
|
|
1493
|
+
font-size: 32px;
|
|
1494
|
+
line-height: 32px;
|
|
1495
|
+
color: var(--app-faint);
|
|
1496
|
+
}
|
|
1497
|
+
.empty-title {
|
|
1498
|
+
font-size: 15px;
|
|
1499
|
+
line-height: 20px;
|
|
1500
|
+
font-weight: 600;
|
|
1501
|
+
color: var(--app-text);
|
|
1502
|
+
}
|
|
1503
|
+
.empty-body {
|
|
1504
|
+
font-size: 13px;
|
|
1505
|
+
line-height: 18px;
|
|
1506
|
+
color: var(--app-muted);
|
|
1507
|
+
}
|
|
1508
|
+
```
|
|
1509
|
+
|
|
1510
|
+
- The glyph is optional; nothing is an acceptable choice.
|
|
1511
|
+
- The title is one short sentence saying what is missing.
|
|
1512
|
+
- The body is one optional sentence saying how to fix it.
|
|
1513
|
+
- One action, if there is an obvious one, in the Secondary variant.
|
|
1514
|
+
|
|
1515
|
+
Never illustrate an empty state with a large graphic, and never apologise
|
|
1516
|
+
("Oops!", "Nothing to see here!").
|
|
1517
|
+
|
|
1518
|
+
### 11.2 Loading
|
|
1519
|
+
|
|
1520
|
+
- **First load, no data yet:** a centred 18 px ring with a single arc, plus a
|
|
1521
|
+
13/18 px `--color-text-secondary` line ("Loading items…"). Nothing else. This is
|
|
1522
|
+
the canonical spinner — use it verbatim:
|
|
1523
|
+
|
|
1524
|
+
```css
|
|
1525
|
+
.spinner {
|
|
1526
|
+
flex: none;
|
|
1527
|
+
width: 18px;
|
|
1528
|
+
height: 18px;
|
|
1529
|
+
border: 2px solid var(--app-border);
|
|
1530
|
+
border-top-color: var(--app-link);
|
|
1531
|
+
border-radius: var(--app-radius-pill);
|
|
1532
|
+
animation: spinner-rotate 900ms linear infinite;
|
|
1533
|
+
}
|
|
1534
|
+
@keyframes spinner-rotate {
|
|
1535
|
+
to {
|
|
1536
|
+
transform: rotate(360deg);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
The arc is teal (`--happy-link`), not blue, so it matches the ring Happy draws
|
|
1542
|
+
in its own loading state one hairline above yours. Under
|
|
1543
|
+
`prefers-reduced-motion` the animation stops and the ring becomes a static
|
|
1544
|
+
arc — which is why the adjacent "Loading…" text is required, not optional: it
|
|
1545
|
+
is what carries the meaning when nothing moves.
|
|
1546
|
+
|
|
1547
|
+
- **Refreshing existing data:** keep the data on screen. Show a 2 px indeterminate
|
|
1548
|
+
bar at the top of the content region, or dim the affected rows to
|
|
1549
|
+
`opacity: 0.6`. Never blank a populated view.
|
|
1550
|
+
- **Optional:** skeleton blocks — `--color-background-secondary`, the exact height
|
|
1551
|
+
and radius of the real content. If you animate them, respect §13.
|
|
1552
|
+
- Never a full-screen spinner overlay in a plugin app. Never a progress bar that
|
|
1553
|
+
fakes progress.
|
|
1554
|
+
|
|
1555
|
+
### 11.3 Error
|
|
1556
|
+
|
|
1557
|
+
An inline block at the top of the content, not a modal, not a toast:
|
|
1558
|
+
|
|
1559
|
+
```css
|
|
1560
|
+
.error {
|
|
1561
|
+
display: flex;
|
|
1562
|
+
flex-direction: column;
|
|
1563
|
+
gap: 8px;
|
|
1564
|
+
padding: 12px;
|
|
1565
|
+
border-radius: var(--border-radius-md, 8px);
|
|
1566
|
+
background: var(--color-background-danger);
|
|
1567
|
+
border: 1px solid var(--color-border-danger);
|
|
1568
|
+
color: var(--color-text-primary);
|
|
1569
|
+
}
|
|
1570
|
+
.error-title {
|
|
1571
|
+
font-size: 14px;
|
|
1572
|
+
line-height: 20px;
|
|
1573
|
+
font-weight: 600;
|
|
1574
|
+
color: var(--color-text-danger);
|
|
1575
|
+
}
|
|
1576
|
+
```
|
|
1577
|
+
|
|
1578
|
+
- Say what failed and what the user can do. Show the underlying message in
|
|
1579
|
+
`--font-mono` 12/18 px only if it is actionable.
|
|
1580
|
+
- Offer a retry **action** if retrying can help. This is not the forbidden
|
|
1581
|
+
"Refresh" button — it is recovery from a specific failure.
|
|
1582
|
+
- Never `alert()`, never `console.error` as the only surface.
|
|
1583
|
+
|
|
1584
|
+
### 11.4 Disabled and read-only
|
|
1585
|
+
|
|
1586
|
+
- Disabled: `--color-background-disabled`, `--color-text-disabled`, no hover,
|
|
1587
|
+
`cursor: default`, and the real `disabled` attribute so it leaves the tab order.
|
|
1588
|
+
- Read-only content: normal colours, no control affordance. Do not render a
|
|
1589
|
+
disabled input to show a value — render text.
|
|
1590
|
+
- Never disable a control without an adjacent explanation of why.
|
|
1591
|
+
|
|
1592
|
+
### 11.5 Staying current
|
|
1593
|
+
|
|
1594
|
+
Your app must keep itself up to date. When Happy tells you data changed — a new
|
|
1595
|
+
tool result, an instance `dataRevision` bump — re-read and re-render without a
|
|
1596
|
+
remount and without the user asking. Do not ship a "Refresh" button whose only
|
|
1597
|
+
job is to re-fetch.
|
|
1598
|
+
|
|
1599
|
+
### 11.6 Connecting, and failing to connect
|
|
1600
|
+
|
|
1601
|
+
Before the four data states there is a fifth: the handshake (§1.1). `useHappyApp`
|
|
1602
|
+
gives you `isConnected` and `error`, and you must render something sensible for
|
|
1603
|
+
every combination of them. This is not optional — an unhandled handshake shows the
|
|
1604
|
+
user a blank document.
|
|
1605
|
+
|
|
1606
|
+
```tsx
|
|
1607
|
+
if (error) return <ErrorBlock title="This app could not start." detail={error.message} />;
|
|
1608
|
+
if (!isConnected) return <Loading label="Starting…" />;
|
|
1609
|
+
if (items === undefined) return <Loading label="Loading items…" />;
|
|
1610
|
+
if (items.length === 0) return <Empty />;
|
|
1611
|
+
return <List items={items} />;
|
|
1612
|
+
```
|
|
1613
|
+
|
|
1614
|
+
- While `!isConnected`, render the §11.2 first-load state. Do not render your
|
|
1615
|
+
populated layout with placeholder data, and do not render nothing.
|
|
1616
|
+
- When `error` is set the app never connected: there is no host to retry through
|
|
1617
|
+
and no tool you can call. Render the §11.3 block with no retry action, and say
|
|
1618
|
+
that the app could not start rather than that data could not load.
|
|
1619
|
+
- Everything in these two states is painted from your §5.4 fallbacks, because no
|
|
1620
|
+
host variable has arrived yet. Check that they are legible.
|
|
1621
|
+
|
|
1622
|
+
---
|
|
1623
|
+
|
|
1624
|
+
## 12. Focus, keyboard, and accessibility
|
|
1625
|
+
|
|
1626
|
+
- **Every** interactive element must show a focus ring:
|
|
1627
|
+
`outline: 2px solid var(--color-ring-primary); outline-offset: 2px;` under
|
|
1628
|
+
`:focus-visible`. Never `outline: none` without an equally visible replacement.
|
|
1629
|
+
- Never set a positive `tabindex`. DOM order is tab order; make DOM order match
|
|
1630
|
+
visual order.
|
|
1631
|
+
- Use real elements: `<button>`, `<a href>`, `<input>`, `<select>`, `<table>`,
|
|
1632
|
+
`<ul>`. A `<div onclick>` is a defect.
|
|
1633
|
+
- Give every icon-only control an `aria-label`. Mark decorative glyphs
|
|
1634
|
+
`aria-hidden="true"`.
|
|
1635
|
+
- One `<h1>` per document (your content's title, not the app name Happy already
|
|
1636
|
+
shows), then `<h2>`/`<h3>` in order, never skipping a level.
|
|
1637
|
+
- Keyboard contracts you must honour: Enter and Space activate a button; Escape
|
|
1638
|
+
closes a popover or cancels an inline edit; arrow keys move within a tablist,
|
|
1639
|
+
menu, or grid; Tab never enters a closed disclosure.
|
|
1640
|
+
- Announce asynchronous changes with `aria-live="polite"` on the status region.
|
|
1641
|
+
- Respect `prefers-reduced-motion` (§13).
|
|
1642
|
+
- Do not trap focus unless you have opened a modal you own, and then return focus
|
|
1643
|
+
to the trigger on close.
|
|
1644
|
+
- Do not autofocus on load in an inline card; the user did not ask you to steal
|
|
1645
|
+
the caret from the chat composer. Autofocus is acceptable in a modal or a page
|
|
1646
|
+
whose sole purpose is that field.
|
|
1647
|
+
|
|
1648
|
+
---
|
|
1649
|
+
|
|
1650
|
+
## 13. Motion and performance
|
|
1651
|
+
|
|
1652
|
+
Happy's surfaces are still. Motion is for state feedback, never for arrival.
|
|
1653
|
+
|
|
1654
|
+
Permitted:
|
|
1655
|
+
|
|
1656
|
+
| Change | Duration | Easing |
|
|
1657
|
+
| --------------------------- | -------- | ---------- |
|
|
1658
|
+
| Hover / press colour | 120 ms | `ease` |
|
|
1659
|
+
| Expand / collapse a section | 160 ms | `ease-out` |
|
|
1660
|
+
| Popover appear | 120 ms | `ease-out` |
|
|
1661
|
+
| Indeterminate progress | loop | linear |
|
|
1662
|
+
|
|
1663
|
+
Forbidden: entrance animations on page or list load, parallax, animated
|
|
1664
|
+
gradients, bouncing, spinning icons that are not progress, anything over 200 ms,
|
|
1665
|
+
and animating `width`, `height`, `top`, or `left`. Animate only `opacity` and
|
|
1666
|
+
`transform`.
|
|
1667
|
+
|
|
1668
|
+
```css
|
|
1669
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1670
|
+
*,
|
|
1671
|
+
*::before,
|
|
1672
|
+
*::after {
|
|
1673
|
+
animation-duration: 0.01ms !important;
|
|
1674
|
+
animation-iteration-count: 1 !important;
|
|
1675
|
+
transition-duration: 0.01ms !important;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
Performance:
|
|
1681
|
+
|
|
1682
|
+
- The whole app is one HTML document. Keep it small; no framework beyond React,
|
|
1683
|
+
which the SDK already bundles.
|
|
1684
|
+
- No network requests to third-party origins for fonts, icons, analytics, or CSS.
|
|
1685
|
+
Data comes through MCP tool calls.
|
|
1686
|
+
- Virtualise any list that can exceed a few hundred rows.
|
|
1687
|
+
- Do not observe `resize` at high frequency; the SDK's auto-resize already reports
|
|
1688
|
+
your content height on animation frames.
|
|
1689
|
+
- Do not poll on a timer for data Happy pushes to you.
|
|
1690
|
+
|
|
1691
|
+
---
|
|
1692
|
+
|
|
1693
|
+
## 14. Icons
|
|
1694
|
+
|
|
1695
|
+
You have no access to Happy's icon fonts (Ionicons and Octicons), and there is no
|
|
1696
|
+
channel to get them. So:
|
|
1697
|
+
|
|
1698
|
+
1. **Prefer no icon.** Most rows and buttons read better with a word. This is the
|
|
1699
|
+
default answer.
|
|
1700
|
+
2. When an icon is genuinely needed, use one from the set below. Do not invent a
|
|
1701
|
+
different one, and do not mix drawing styles within an app.
|
|
1702
|
+
|
|
1703
|
+
These seven cover nearly every plugin app. Copy them verbatim; they share one
|
|
1704
|
+
grid, one stroke weight, and one cap style, which is what makes a set look like a
|
|
1705
|
+
set rather than seven unrelated drawings.
|
|
1706
|
+
|
|
1707
|
+
```tsx
|
|
1708
|
+
/* All icons: 16x16 box, 1.5px stroke, round caps and joins, currentColor. */
|
|
1709
|
+
const icon = {
|
|
1710
|
+
width: 16,
|
|
1711
|
+
height: 16,
|
|
1712
|
+
viewBox: "0 0 16 16",
|
|
1713
|
+
fill: "none",
|
|
1714
|
+
stroke: "currentColor",
|
|
1715
|
+
strokeWidth: 1.5,
|
|
1716
|
+
strokeLinecap: "round" as const,
|
|
1717
|
+
strokeLinejoin: "round" as const,
|
|
1718
|
+
"aria-hidden": true,
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
export const ChevronDown = () => (
|
|
1722
|
+
<svg {...icon}>
|
|
1723
|
+
<path d="M4 6.5 8 10.5l4-4" />
|
|
1724
|
+
</svg>
|
|
1725
|
+
);
|
|
1726
|
+
export const ChevronRight = () => (
|
|
1727
|
+
<svg {...icon}>
|
|
1728
|
+
<path d="M6.5 4 10.5 8l-4 4" />
|
|
1729
|
+
</svg>
|
|
1730
|
+
);
|
|
1731
|
+
export const Close = () => (
|
|
1732
|
+
<svg {...icon}>
|
|
1733
|
+
<path d="M4 4l8 8M12 4l-8 8" />
|
|
1734
|
+
</svg>
|
|
1735
|
+
);
|
|
1736
|
+
export const Plus = () => (
|
|
1737
|
+
<svg {...icon}>
|
|
1738
|
+
<path d="M8 3.5v9M3.5 8h9" />
|
|
1739
|
+
</svg>
|
|
1740
|
+
);
|
|
1741
|
+
export const Check = () => (
|
|
1742
|
+
<svg {...icon}>
|
|
1743
|
+
<path d="M3.5 8.5l3 3 6-7" />
|
|
1744
|
+
</svg>
|
|
1745
|
+
);
|
|
1746
|
+
export const Search = () => (
|
|
1747
|
+
<svg {...icon}>
|
|
1748
|
+
<circle cx="7" cy="7" r="3.75" />
|
|
1749
|
+
<path d="M10 10l2.5 2.5" />
|
|
1750
|
+
</svg>
|
|
1751
|
+
);
|
|
1752
|
+
export const More = () => (
|
|
1753
|
+
<svg {...icon} fill="currentColor" stroke="none">
|
|
1754
|
+
<circle cx="3.5" cy="8" r="1.25" />
|
|
1755
|
+
<circle cx="8" cy="8" r="1.25" />
|
|
1756
|
+
<circle cx="12.5" cy="8" r="1.25" />
|
|
1757
|
+
</svg>
|
|
1758
|
+
);
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
Rules:
|
|
1762
|
+
|
|
1763
|
+
- 16 px in rows, buttons, and menus. 20 px beside a page title. 32 px in an empty
|
|
1764
|
+
state — scale the same `viewBox`, never redraw at a new size.
|
|
1765
|
+
- `currentColor` only, so an icon follows its container's text colour and
|
|
1766
|
+
therefore the theme, with no extra work.
|
|
1767
|
+
- `aria-hidden="true"` on the SVG, and the meaning in the button's `aria-label` or
|
|
1768
|
+
in adjacent text.
|
|
1769
|
+
- Never load an icon font, an icon sprite, or an SVG over the network.
|
|
1770
|
+
- Never use an emoji as a control's icon.
|
|
1771
|
+
- A Unicode text glyph (`↑ ↓ ✓ ×`) is acceptable in a dense table cell where a
|
|
1772
|
+
full SVG would be noise. Wrap it the same way.
|
|
1773
|
+
|
|
1774
|
+
---
|
|
1775
|
+
|
|
1776
|
+
## 15. Writing
|
|
1777
|
+
|
|
1778
|
+
- Sentence case everywhere: labels, buttons, headings, menu items.
|
|
1779
|
+
- Verb-first actions: "Add item", "Delete list", "Retry".
|
|
1780
|
+
- No exclamation marks. No "Oops", "Whoops", "Uh-oh", "Awesome", or "Please".
|
|
1781
|
+
- State facts: "3 items", "Last updated 2 minutes ago", "Failed to load items".
|
|
1782
|
+
- Empty and error copy is one sentence of what, one sentence of what to do.
|
|
1783
|
+
- Never expose internal identifiers, stack traces, or tool names in user-facing
|
|
1784
|
+
copy unless they are the point of the view.
|
|
1785
|
+
|
|
1786
|
+
---
|
|
1787
|
+
|
|
1788
|
+
## 16. Anti-patterns
|
|
1789
|
+
|
|
1790
|
+
Each of these is a defect. They are listed because they are what a model
|
|
1791
|
+
generating a web page tends to do by default.
|
|
1792
|
+
|
|
1793
|
+
**Colour and theme**
|
|
1794
|
+
|
|
1795
|
+
1. A hex, `rgb()`, `hsl()`, or named colour anywhere except inside a `var()`
|
|
1796
|
+
fallback. The keywords `currentColor`, `transparent`, `inherit`, and `none` are
|
|
1797
|
+
not colours in this sense and are always allowed — they are how the icon set
|
|
1798
|
+
(§14) and the ghost border follow the theme.
|
|
1799
|
+
2. Tailwind-style palette names (`slate-800`, `blue-500`) or a bundled CSS
|
|
1800
|
+
framework.
|
|
1801
|
+
3. A gradient of any kind — background, text, border, or button.
|
|
1802
|
+
4. `@media (prefers-color-scheme)` to choose colours instead of the variables.
|
|
1803
|
+
5. Reading a variable in JavaScript and storing the resolved colour.
|
|
1804
|
+
6. Aliasing a host variable to itself (`--happy-canvas: var(--happy-canvas, …)`).
|
|
1805
|
+
7. Repeating a fallback at every use site instead of once in `:root`.
|
|
1806
|
+
8. A dark-mode-only or light-mode-only design.
|
|
1807
|
+
|
|
1808
|
+
**Layout**
|
|
1809
|
+
|
|
1810
|
+
9. `float`, layout `<table>`, `inline-block` spacing, or absolute positioning for
|
|
1811
|
+
layout (§4.1).
|
|
1812
|
+
10. Margins between siblings instead of the parent's `gap` (§4.2).
|
|
1813
|
+
11. A flex child that can shrink without `min-width: 0` (§4.3).
|
|
1814
|
+
12. Padding on the scrollport instead of on an inner wrapper (§4.4).
|
|
1815
|
+
13. Two nested vertical scrollbars (§4.4).
|
|
1816
|
+
14. `100vh` or `min-height: 100vh` anywhere. On a fullscreen surface it is merely
|
|
1817
|
+
redundant with `100%`. On an **inline** surface it is a ratchet: your content
|
|
1818
|
+
is then always at least as tall as the frame, so the height you report can
|
|
1819
|
+
never shrink and the card is stuck at its maximum forever.
|
|
1820
|
+
15. `height: 100%` on `<html>`/`<body>` in an **inline** surface — same ratchet,
|
|
1821
|
+
and it reports 800 px from the first frame (§2.2).
|
|
1822
|
+
16. Laying out from `containerDimensions` in JavaScript; it does not survive a
|
|
1823
|
+
window resize (§1.1).
|
|
1824
|
+
17. Mobile breakpoints, a hamburger menu, or touch-sized targets (§2).
|
|
1825
|
+
|
|
1826
|
+
**Chrome and identity**
|
|
1827
|
+
|
|
1828
|
+
18. A fake title bar, window controls, or a repeat of the app's title (§1.4).
|
|
1829
|
+
19. A left navigation rail that imitates Happy's feature rail (§1.4).
|
|
1830
|
+
20. A full-viewport scrim for something that is not a dialog you own (§9.8).
|
|
1831
|
+
21. `window.open` or `target="_blank"` instead of a capability-checked
|
|
1832
|
+
`app.openLink` (§9.9).
|
|
1833
|
+
22. Shipping a web font, an icon font, a Google Fonts `@import`, or a CDN
|
|
1834
|
+
stylesheet (§6.1, §14).
|
|
1835
|
+
|
|
1836
|
+
**Type and density**
|
|
1837
|
+
|
|
1838
|
+
23. Text below 11 px, or a weight outside 400/500/600 (§6.2).
|
|
1839
|
+
24. A 32 px hero heading, centred marketing copy, or an illustration band (§8).
|
|
1840
|
+
25. Layout spacing off the 4 px scale (§3) — but note that control-internal
|
|
1841
|
+
padding is deliberately not on it.
|
|
1842
|
+
26. Zebra-striped tables, coloured row backgrounds, or shadowed buttons (§7, §9.4).
|
|
1843
|
+
27. Emoji as UI iconography, or a hand-drawn icon outside the §14 set.
|
|
1844
|
+
|
|
1845
|
+
**Behaviour**
|
|
1846
|
+
|
|
1847
|
+
28. Rendering nothing, or a populated layout with placeholder data, while the
|
|
1848
|
+
handshake is still in flight (§11.6).
|
|
1849
|
+
29. A "Refresh" button as the only way to get current data (§11.5).
|
|
1850
|
+
30. A full-screen loading overlay, or blanking a populated view while refreshing.
|
|
1851
|
+
31. `alert()`, `confirm()`, or `prompt()`.
|
|
1852
|
+
32. Entrance animations, or any transition longer than 200 ms (§13).
|
|
1853
|
+
33. `outline: none` without a replacement focus indicator (§12).
|
|
1854
|
+
34. A `<div>` with an `onclick` and no role, tabindex, or key handler (§12).
|
|
1855
|
+
|
|
1856
|
+
---
|
|
1857
|
+
|
|
1858
|
+
## 17. A complete baseline
|
|
1859
|
+
|
|
1860
|
+
Copy this as the first stylesheet of a new plugin app. It implements §5.4, §3,
|
|
1861
|
+
§4, §6, and §12, and nothing else — every component style is yours to add from §9.
|
|
1862
|
+
|
|
1863
|
+
```css
|
|
1864
|
+
/* ---- Host variable mapping and fallbacks (declare once) --------------- */
|
|
1865
|
+
:root {
|
|
1866
|
+
color-scheme: light dark;
|
|
1867
|
+
|
|
1868
|
+
--app-surface: var(--color-background-primary, light-dark(#ffffff, #212121));
|
|
1869
|
+
--app-raised: var(--color-background-secondary, light-dark(#f8f8f8, #171717));
|
|
1870
|
+
--app-inset: var(--color-background-tertiary, light-dark(#f0f0f2, #2c2c2e));
|
|
1871
|
+
--app-disabled: var(--color-background-disabled, light-dark(#f0f0f2, #2c2c2e));
|
|
1872
|
+
--app-ghost: var(
|
|
1873
|
+
--color-background-ghost,
|
|
1874
|
+
light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.08))
|
|
1875
|
+
);
|
|
1876
|
+
--app-canvas: var(--happy-canvas, light-dark(#f5f5f5, #1e1e1e));
|
|
1877
|
+
--app-code: var(--happy-code-background, light-dark(#f6f8fa, #161b22));
|
|
1878
|
+
|
|
1879
|
+
--app-text: var(--color-text-primary, light-dark(#000000, #ffffff));
|
|
1880
|
+
--app-muted: var(--color-text-secondary, light-dark(#49454f, #cac4d0));
|
|
1881
|
+
--app-faint: var(--color-text-tertiary, light-dark(#807d84, #97939c));
|
|
1882
|
+
--app-on-action: var(--color-text-inverse, #ffffff);
|
|
1883
|
+
--app-disabled-text: var(--color-text-disabled, light-dark(#adabb0, #6d6a70));
|
|
1884
|
+
|
|
1885
|
+
--app-border: var(--color-border-primary, light-dark(#eaeaea, #292929));
|
|
1886
|
+
--app-border-strong: var(--color-border-secondary, light-dark(#cccccc, #4d4d4d));
|
|
1887
|
+
--app-accent: var(--color-ring-primary, light-dark(#007aff, #0a84ff));
|
|
1888
|
+
--app-link: var(--happy-link, #2baccc);
|
|
1889
|
+
--app-action: var(--color-background-inverse, #000000);
|
|
1890
|
+
|
|
1891
|
+
--app-danger: var(--color-text-danger, light-dark(#f44336, #f48fb1));
|
|
1892
|
+
--app-success: var(--color-text-success, light-dark(#34c759, #32d74b));
|
|
1893
|
+
--app-warning: var(--color-text-warning, light-dark(#ff9500, #ffab00));
|
|
1894
|
+
--app-info: var(--color-text-info, light-dark(#007aff, #0a84ff));
|
|
1895
|
+
|
|
1896
|
+
/* The soft semantic fills. The host composes each from its own hue over the
|
|
1897
|
+
surface; these fallbacks are the same mixes written out. */
|
|
1898
|
+
--app-danger-soft: var(--color-background-danger, light-dark(#fff0f0, rgb(255 69 58 / 0.15)));
|
|
1899
|
+
--app-success-soft: var(
|
|
1900
|
+
--color-background-success,
|
|
1901
|
+
light-dark(rgb(52 199 89 / 0.14), rgb(50 215 75 / 0.14))
|
|
1902
|
+
);
|
|
1903
|
+
--app-warning-soft: var(
|
|
1904
|
+
--color-background-warning,
|
|
1905
|
+
light-dark(#fff8f0, rgb(255 159 10 / 0.15))
|
|
1906
|
+
);
|
|
1907
|
+
--app-info-soft: var(
|
|
1908
|
+
--color-background-info,
|
|
1909
|
+
light-dark(rgb(0 122 255 / 0.12), rgb(10 132 255 / 0.12))
|
|
1910
|
+
);
|
|
1911
|
+
|
|
1912
|
+
--app-radius-control: var(--border-radius-sm, 6px);
|
|
1913
|
+
--app-radius-block: var(--border-radius-md, 8px);
|
|
1914
|
+
--app-radius-card: var(--border-radius-lg, 10px);
|
|
1915
|
+
--app-radius-pill: var(--border-radius-full, 999px);
|
|
1916
|
+
|
|
1917
|
+
--app-font: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
1918
|
+
--app-mono: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
/* ---- Document ---------------------------------------------------------- */
|
|
1922
|
+
*,
|
|
1923
|
+
*::before,
|
|
1924
|
+
*::after {
|
|
1925
|
+
box-sizing: border-box;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
html,
|
|
1929
|
+
body {
|
|
1930
|
+
margin: 0;
|
|
1931
|
+
padding: 0;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/* Include EXACTLY ONE of the following two blocks.
|
|
1935
|
+
*
|
|
1936
|
+
* FULLSCREEN (surfaces 2-5): the host owns the height, so fill the frame. */
|
|
1937
|
+
html,
|
|
1938
|
+
body,
|
|
1939
|
+
#root {
|
|
1940
|
+
height: 100%;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/* INLINE (surface 1): you own the height, so include NOTHING here — no height,
|
|
1944
|
+
* no min-height, no vh — and the document sizes to its content, which is what
|
|
1945
|
+
* auto-resize reports to the host. */
|
|
1946
|
+
|
|
1947
|
+
body {
|
|
1948
|
+
font-family: var(--app-font);
|
|
1949
|
+
font-size: 14px;
|
|
1950
|
+
line-height: 20px;
|
|
1951
|
+
font-weight: 400;
|
|
1952
|
+
font-synthesis: none;
|
|
1953
|
+
color: var(--app-text);
|
|
1954
|
+
background: var(--app-surface);
|
|
1955
|
+
-webkit-font-smoothing: antialiased;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
#root {
|
|
1959
|
+
display: flex;
|
|
1960
|
+
flex-direction: column;
|
|
1961
|
+
min-height: 0;
|
|
1962
|
+
width: 100%;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/* ---- Shared primitives -------------------------------------------------- */
|
|
1966
|
+
.row {
|
|
1967
|
+
display: flex;
|
|
1968
|
+
align-items: center;
|
|
1969
|
+
gap: 8px;
|
|
1970
|
+
}
|
|
1971
|
+
.column {
|
|
1972
|
+
display: flex;
|
|
1973
|
+
flex-direction: column;
|
|
1974
|
+
gap: 8px;
|
|
1975
|
+
}
|
|
1976
|
+
.grow {
|
|
1977
|
+
flex: 1 1 auto;
|
|
1978
|
+
min-width: 0;
|
|
1979
|
+
}
|
|
1980
|
+
.fixed {
|
|
1981
|
+
flex: none;
|
|
1982
|
+
}
|
|
1983
|
+
.truncate {
|
|
1984
|
+
overflow: hidden;
|
|
1985
|
+
text-overflow: ellipsis;
|
|
1986
|
+
white-space: nowrap;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
.scrollport {
|
|
1990
|
+
flex: 1 1 auto;
|
|
1991
|
+
min-height: 0;
|
|
1992
|
+
width: 100%;
|
|
1993
|
+
overflow-y: auto;
|
|
1994
|
+
overflow-x: hidden;
|
|
1995
|
+
scrollbar-width: thin;
|
|
1996
|
+
scrollbar-color: var(--happy-scrollbar-thumb, rgb(150 150 150 / 0.4)) transparent;
|
|
1997
|
+
}
|
|
1998
|
+
.scrollport-content {
|
|
1999
|
+
display: flex;
|
|
2000
|
+
flex-direction: column;
|
|
2001
|
+
gap: 12px;
|
|
2002
|
+
padding: 16px;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
code,
|
|
2006
|
+
pre,
|
|
2007
|
+
.mono {
|
|
2008
|
+
font-family: var(--app-mono);
|
|
2009
|
+
font-size: 12px;
|
|
2010
|
+
line-height: 18px;
|
|
2011
|
+
}
|
|
2012
|
+
pre {
|
|
2013
|
+
margin: 0;
|
|
2014
|
+
padding: 12px;
|
|
2015
|
+
/* Horizontal only: a long log line scrolls sideways, but the block grows to
|
|
2016
|
+
its content vertically, so it never becomes a second vertical scrollport
|
|
2017
|
+
(§4.4). Never add `max-height` with `overflow-y: auto` here. An unbounded
|
|
2018
|
+
log is laid out the other way round: the `pre` becomes the view's one
|
|
2019
|
+
scrollport itself — `flex: 1 1 auto; min-height: 0; overflow: auto` inside
|
|
2020
|
+
the body, with no other scrolling element — or it stays in normal flow and
|
|
2021
|
+
you cap the number of lines you render. */
|
|
2022
|
+
overflow-x: auto;
|
|
2023
|
+
background: var(--app-code);
|
|
2024
|
+
border-radius: var(--app-radius-block);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
a {
|
|
2028
|
+
color: var(--app-link);
|
|
2029
|
+
text-decoration: none;
|
|
2030
|
+
}
|
|
2031
|
+
a:hover {
|
|
2032
|
+
text-decoration: underline;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
.spinner {
|
|
2036
|
+
flex: none;
|
|
2037
|
+
width: 18px;
|
|
2038
|
+
height: 18px;
|
|
2039
|
+
border: 2px solid var(--app-border);
|
|
2040
|
+
border-top-color: var(--app-link);
|
|
2041
|
+
border-radius: var(--app-radius-pill);
|
|
2042
|
+
animation: spinner-rotate 900ms linear infinite;
|
|
2043
|
+
}
|
|
2044
|
+
@keyframes spinner-rotate {
|
|
2045
|
+
to {
|
|
2046
|
+
transform: rotate(360deg);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
:focus-visible {
|
|
2051
|
+
outline: 2px solid var(--app-accent);
|
|
2052
|
+
outline-offset: 2px;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2056
|
+
*,
|
|
2057
|
+
*::before,
|
|
2058
|
+
*::after {
|
|
2059
|
+
animation-duration: 0.01ms !important;
|
|
2060
|
+
animation-iteration-count: 1 !important;
|
|
2061
|
+
transition-duration: 0.01ms !important;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
```
|
|
2065
|
+
|
|
2066
|
+
And the shape of a fullscreen page built on it:
|
|
2067
|
+
|
|
2068
|
+
```tsx
|
|
2069
|
+
function Page() {
|
|
2070
|
+
const { hostContext, isConnected } = useHappyApp({
|
|
2071
|
+
appInfo: { name: "example", version: "1.0.0" },
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
return (
|
|
2075
|
+
<div className="page">
|
|
2076
|
+
<div className="toolbar">
|
|
2077
|
+
<h1 className="page-title grow truncate">Deployments</h1>
|
|
2078
|
+
<button className="btn btn-secondary fixed" type="button">
|
|
2079
|
+
Filter
|
|
2080
|
+
</button>
|
|
2081
|
+
<button className="btn btn-primary fixed" type="button">
|
|
2082
|
+
New deployment
|
|
2083
|
+
</button>
|
|
2084
|
+
</div>
|
|
2085
|
+
<div className="scrollport">
|
|
2086
|
+
<div className="scrollport-content">{/* rows, cards, table */}</div>
|
|
2087
|
+
</div>
|
|
2088
|
+
</div>
|
|
2089
|
+
);
|
|
2090
|
+
}
|
|
2091
|
+
```
|
|
2092
|
+
|
|
2093
|
+
```css
|
|
2094
|
+
.page {
|
|
2095
|
+
display: flex;
|
|
2096
|
+
flex-direction: column;
|
|
2097
|
+
height: 100%;
|
|
2098
|
+
min-height: 0;
|
|
2099
|
+
background: var(--app-surface);
|
|
2100
|
+
}
|
|
2101
|
+
.page-title {
|
|
2102
|
+
font-size: 20px;
|
|
2103
|
+
line-height: 26px;
|
|
2104
|
+
font-weight: 600;
|
|
2105
|
+
letter-spacing: -0.01em;
|
|
2106
|
+
margin: 0;
|
|
2107
|
+
}
|
|
2108
|
+
```
|
|
2109
|
+
|
|
2110
|
+
---
|
|
2111
|
+
|
|
2112
|
+
## 18. Acceptance checklist
|
|
2113
|
+
|
|
2114
|
+
Verify each item by measurement, not by looking. Where a number is given, read it
|
|
2115
|
+
out of `getComputedStyle` or the DevTools box model at **2× device pixel ratio**,
|
|
2116
|
+
in **both** light and dark, at **three** container widths: **400**, **640**, and
|
|
2117
|
+
**1180** CSS pixels.
|
|
2118
|
+
|
|
2119
|
+
**Theme**
|
|
2120
|
+
|
|
2121
|
+
- [ ] `document.documentElement.style` carries `--color-background-primary` and
|
|
2122
|
+
`--happy-canvas` once the app has connected.
|
|
2123
|
+
- [ ] Before it connects the app already renders legibly from the §5.4 fallbacks —
|
|
2124
|
+
no flash of unstyled or invisible content.
|
|
2125
|
+
- [ ] No rule is keyed on `[data-theme]`.
|
|
2126
|
+
- [ ] Toggling Happy between light and dark repaints the app with no reload, and
|
|
2127
|
+
every visible colour changes appropriately.
|
|
2128
|
+
- [ ] A grep of the app's CSS and TSX finds **zero** hex, `rgb(`, `hsl(`, or
|
|
2129
|
+
named colours outside `var(…, fallback)` positions.
|
|
2130
|
+
- [ ] Opened standalone in a browser tab with no host, the app is still legible
|
|
2131
|
+
in both OS appearances (the fallbacks work) and no variable is cyclic.
|
|
2132
|
+
|
|
2133
|
+
**Layout**
|
|
2134
|
+
|
|
2135
|
+
- [ ] `document.body` computed `margin` is `0px`.
|
|
2136
|
+
- [ ] On a fullscreen surface the outermost element fills the frame
|
|
2137
|
+
(`clientHeight === document.documentElement.clientHeight`) and there is no
|
|
2138
|
+
page-level scrollbar on `<body>`. Verify after resizing the host window, not
|
|
2139
|
+
only at first load.
|
|
2140
|
+
- [ ] On an inline surface the reported height equals the content height and is
|
|
2141
|
+
between 120 and 800.
|
|
2142
|
+
- [ ] The scrollport's computed `padding` and `margin` are both `0px`, and its
|
|
2143
|
+
box exactly matches its parent's content box.
|
|
2144
|
+
- [ ] Exactly one element in the view has a vertical scrollbar.
|
|
2145
|
+
- [ ] Every flex `gap`, every container padding, and every margin between elements
|
|
2146
|
+
is 0, 4, 8, 12, 16, 24, 32, or 48 px. (Padding _inside_ a control is exempt —
|
|
2147
|
+
it comes from §9 verbatim.)
|
|
2148
|
+
- [ ] No rule anywhere uses `vh` units.
|
|
2149
|
+
- [ ] At 400 px wide nothing overflows horizontally
|
|
2150
|
+
(`document.documentElement.scrollWidth <= clientWidth`).
|
|
2151
|
+
- [ ] A row with a 200-character title ellipsizes and its trailing controls stay
|
|
2152
|
+
at their declared width.
|
|
2153
|
+
|
|
2154
|
+
**Type**
|
|
2155
|
+
|
|
2156
|
+
- [ ] No computed `font-size` below `11px`.
|
|
2157
|
+
- [ ] Every text element's `font-family` resolves through `--font-sans` or
|
|
2158
|
+
`--font-mono`; no other family appears.
|
|
2159
|
+
- [ ] Every `font-weight` is 400, 500, or 600.
|
|
2160
|
+
- [ ] Each `font-size`/`line-height` pair matches a row of the §6.2 table.
|
|
2161
|
+
|
|
2162
|
+
**Shape**
|
|
2163
|
+
|
|
2164
|
+
- [ ] Buttons and inputs are exactly 28, 36, or 44 px tall.
|
|
2165
|
+
- [ ] Every `border-radius` is 0, 6, 8, 10, 14, or 999 px.
|
|
2166
|
+
- [ ] Every border is exactly 1 px, except the loading ring and focus rings,
|
|
2167
|
+
which are 2 px.
|
|
2168
|
+
- [ ] For each nested rounded corner, `inner === max(0, outer − inset)`.
|
|
2169
|
+
- [ ] No `box-shadow` on a button, input, row, chip, or table.
|
|
2170
|
+
|
|
2171
|
+
**Accent and contrast**
|
|
2172
|
+
|
|
2173
|
+
- [ ] The accent appears only in focus rings, form-control selection
|
|
2174
|
+
(`accent-color`), progress, and info text — never on a selected row or tab,
|
|
2175
|
+
which are neutral (§10).
|
|
2176
|
+
- [ ] Body text contrast is ≥ 4.5 : 1 against its background in both schemes.
|
|
2177
|
+
- [ ] Focus rings are ≥ 3 : 1 against the adjacent surface in both schemes.
|
|
2178
|
+
|
|
2179
|
+
**Interaction**
|
|
2180
|
+
|
|
2181
|
+
- [ ] Tabbing reaches every control in visual order and each shows a 2 px ring
|
|
2182
|
+
(offset 2 outward, or −1 inside a filled input), fully inside the scrollport.
|
|
2183
|
+
- [ ] Enter and Space activate every clickable thing; Escape closes every menu and
|
|
2184
|
+
dialog and returns focus to its trigger.
|
|
2185
|
+
- [ ] Every icon-only control has an `aria-label`.
|
|
2186
|
+
- [ ] All six states — connecting, connect-failed, loading, empty, error,
|
|
2187
|
+
populated — render correctly at each of the three widths.
|
|
2188
|
+
- [ ] No transition exceeds 200 ms; `prefers-reduced-motion` disables them all and
|
|
2189
|
+
the loading state still reads without motion.
|
|
2190
|
+
- [ ] There is no "Refresh" button, no `alert()`, no `window.open`, and every
|
|
2191
|
+
external link is capability-checked.
|