teacup-os 0.1.0 → 0.1.1

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/dist/teacup.js CHANGED
@@ -145,6 +145,11 @@ function validateManifest(m, folder2, opts = {}) {
145
145
  typeof m.description === "string" && m.description.length >= 10 && m.description.length <= 140,
146
146
  "description must be one sentence of 10 to 140 characters"
147
147
  );
148
+ const sub = m.subtitle;
149
+ need(
150
+ sub === void 0 || words(sub, 3, 40) && !/[.!]$/.test(sub),
151
+ "subtitle must be 3 to 40 characters, no full stop"
152
+ );
148
153
  need(m.kind === "kit" || m.kind === "framed", "kind must be kit or framed");
149
154
  if (opts.outside)
150
155
  need(m.url === `https://${m.id}.backed.cc/v/${m.version}/`, "an outside app lives at its own address on backed.cc");
@@ -346,9 +351,23 @@ function smallTextBoxes(file, dir) {
346
351
  }
347
352
  return out;
348
353
  }
349
- function iconProblems(icon) {
354
+ const ICON_MAX_BYTES = 4e3;
355
+ function iconProblems(icon, exception = false) {
350
356
  if (!icon.trimStart().startsWith("<svg")) return ["icon.svg is missing or isn't an SVG"];
351
- return /<script|<foreignObject|<iframe|<use|\son[a-z]+\s*=|href\s*=|javascript:/i.test(icon) ? ["icon.svg may only draw shapes: no scripts, event handlers, links or embedded content"] : [];
357
+ if (/<script|<foreignObject|<iframe|<use|<image|\son[a-z]+\s*=|href\s*=|javascript:|url\(\s*['"]?[a-z]+:/i.test(icon))
358
+ return ["icon.svg may only draw shapes: no scripts, event handlers, links, pictures or embedded content"];
359
+ if (exception) return [];
360
+ const problems = [];
361
+ const root = icon.match(/<svg[^>]*>/)?.[0] ?? "";
362
+ if (!/viewBox="0 0 24 24"/.test(root))
363
+ problems.push('icon.svg should be a Lucide glyph on its 24 grid: viewBox="0 0 24 24" (see lucide.dev)');
364
+ if (/<text\b/i.test(icon)) problems.push("icon.svg has words in it; an icon is a glyph, never text");
365
+ const strokes = [...icon.matchAll(/stroke-width\s*[=:]\s*"?([\d.]+)/g)].map((m) => m[1]);
366
+ if (strokes.some((w) => w !== "2"))
367
+ problems.push("icon.svg's stroke should be 2, as every Teacup icon's is (Lucide's own weight)");
368
+ if (Buffer.byteLength(icon) > ICON_MAX_BYTES)
369
+ problems.push(`icon.svg is ${Buffer.byteLength(icon)} bytes; keep it under ${ICON_MAX_BYTES} (one glyph)`);
370
+ return problems;
352
371
  }
353
372
  const glow = (at, colour, clear, size = "120% 90%", reach = "55%") => `radial-gradient(${size} at ${at}, ${colour} 0%, ${clear} ${reach})`;
354
373
  const WALLPAPERS = [
package/kit/icons.ts ADDED
@@ -0,0 +1,39 @@
1
+ // Teacup's one icon family: Lucide (https://lucide.dev), outlines only, ISC licence (kit/lucide/LICENSE). Only the
2
+ // glyphs Teacup uses are copied in, as they come in lucide-static (1.70.0), into kit/lucide/<name>.svg: add one by
3
+ // copying its file there. Every glyph is drawn on Lucide's 24 grid, in the text's colour, with one stroke weight.
4
+
5
+ const files = import.meta.glob<string>("./lucide/*.svg", { query: "?raw", import: "default", eager: true });
6
+
7
+ /** Each glyph's drawing (what's inside its <svg>), by name. */
8
+ export const GLYPHS: Record<string, string> = Object.fromEntries(
9
+ Object.entries(files).map(([path, svg]) => [
10
+ path.replace(/^.*\/|\.svg$/g, ""),
11
+ (svg.match(/<svg[^>]*>([\s\S]*)<\/svg>/)?.[1] ?? "").replace(/\s+/g, " ").trim(),
12
+ ]),
13
+ );
14
+
15
+ /** The stroke every glyph is drawn with: Lucide's own 2 on its 24 grid. */
16
+ export const STROKE = 2;
17
+
18
+ /** A glyph as inline SVG, in the text's colour. */
19
+ export const glyph = (name: string, cls = "glyph", stroke = STROKE) =>
20
+ `<svg class="${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${stroke}" ` +
21
+ `stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${GLYPHS[name] ?? ""}</svg>`;
22
+
23
+ /** The Assistant's orb in the light (apps/assistant/icon.svg is its dark self; shell icons.css draws the same). */
24
+ const ORB_LIGHT =
25
+ "<style>.orb-bg-a{stop-color:#fff}.orb-bg-b{stop-color:#e8e6f3}.orb-sq{fill:#6a4fe8}.orb-core{fill:#14151e}</style>";
26
+
27
+ /** Whether an app's mark is drawn edge to edge (the Assistant's orb), not as a glyph in the middle of its tile. */
28
+ export const isFullMark = (svg: string) => /<svg[^>]*\sdata-full/.test(svg);
29
+
30
+ /**
31
+ * One of Teacup's own app marks as an image address, as it should look now: a mark that follows light and dark
32
+ * (data-themed) carries its light colours unless the page is dark.
33
+ */
34
+ export function markPicture(svg: string): string {
35
+ const dark = globalThis.document?.documentElement.dataset.theme === "dark";
36
+ let s = svg.includes("xmlns") ? svg : svg.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
37
+ if (!dark && s.includes("data-themed")) s = s.replace(/(<svg[^>]*>)/, `$1${ORB_LIGHT}`);
38
+ return `data:image/svg+xml,${encodeURIComponent(s)}`;
39
+ }
@@ -0,0 +1,43 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Lucide Icons and Contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
16
+
17
+ ---
18
+
19
+ The following Lucide icons are derived from the Feather project:
20
+
21
+ airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
22
+
23
+ The MIT License (MIT) (for the icons listed above)
24
+
25
+ Copyright (c) 2013-present Cole Bemis
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy
28
+ of this software and associated documentation files (the "Software"), to deal
29
+ in the Software without restriction, including without limitation the rights
30
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
31
+ copies of the Software, and to permit persons to whom the Software is
32
+ furnished to do so, subject to the following conditions:
33
+
34
+ The above copyright notice and this permission notice shall be included in all
35
+ copies or substantial portions of the Software.
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
40
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
41
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
42
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
43
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-a-large-small"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
15
+ <path d="M15.697 14h5.606" />
16
+ <path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
17
+ <path d="M3.304 13h6.392" />
18
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-bell"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M10.268 21a2 2 0 0 0 3.464 0" />
15
+ <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
16
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-file"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
15
+ <path d="M14 2v5a1 1 0 0 0 1 1h5" />
16
+ </svg>
@@ -0,0 +1,15 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-folder"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
15
+ </svg>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-history"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
15
+ <path d="M3 3v5h5" />
16
+ <path d="M12 7v5l4 2" />
17
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-house"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" />
15
+ <path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
16
+ </svg>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-image"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
15
+ <circle cx="9" cy="9" r="2" />
16
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
17
+ </svg>
@@ -0,0 +1,17 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-info"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <circle cx="12" cy="12" r="10" />
15
+ <path d="M12 16v-4" />
16
+ <path d="M12 8h.01" />
17
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-layout-dashboard"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <rect width="7" height="9" x="3" y="3" rx="1" />
15
+ <rect width="7" height="5" x="14" y="3" rx="1" />
16
+ <rect width="7" height="9" x="14" y="12" rx="1" />
17
+ <rect width="7" height="5" x="3" y="16" rx="1" />
18
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-layout-grid"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <rect width="7" height="7" x="3" y="3" rx="1" />
15
+ <rect width="7" height="7" x="14" y="3" rx="1" />
16
+ <rect width="7" height="7" x="14" y="14" rx="1" />
17
+ <rect width="7" height="7" x="3" y="14" rx="1" />
18
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-search"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="m21 21-4.34-4.34" />
15
+ <circle cx="11" cy="11" r="8" />
16
+ </svg>
@@ -0,0 +1,15 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-shield"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
15
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-sparkles"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" />
15
+ <path d="M20 2v4" />
16
+ <path d="M22 4h-4" />
17
+ <circle cx="4" cy="20" r="2" />
18
+ </svg>
@@ -0,0 +1,19 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-sun-moon"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M12 2v2" />
15
+ <path d="M14.837 16.385a6 6 0 1 1-7.223-7.222c.624-.147.97.66.715 1.248a4 4 0 0 0 5.26 5.259c.589-.255 1.396.09 1.248.715" />
16
+ <path d="M16 12a4 4 0 0 0-4-4" />
17
+ <path d="m19 5-1.256 1.256" />
18
+ <path d="M20 12h2" />
19
+ </svg>
@@ -0,0 +1,19 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-trash-2"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M10 11v6" />
15
+ <path d="M14 11v6" />
16
+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
17
+ <path d="M3 6h18" />
18
+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
19
+ </svg>
@@ -0,0 +1,18 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-users"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
15
+ <path d="M16 3.128a4 4 0 0 1 0 7.744" />
16
+ <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
17
+ <circle cx="9" cy="7" r="4" />
18
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- @license lucide-static v1.70.0 - ISC -->
2
+ <svg
3
+ class="lucide lucide-x"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="24"
6
+ height="24"
7
+ viewBox="0 0 24 24"
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="2"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ >
14
+ <path d="M18 6 6 18" />
15
+ <path d="m6 6 12 12" />
16
+ </svg>
package/kit/src/client.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  // resolves to a stand-alone context, so apps work either way.
4
4
 
5
5
  import { type MenuCall, shortcutOf } from "./menus";
6
+ import { stopPageZoom } from "./no-zoom";
6
7
  import {
7
8
  APP_HEADER,
8
9
  APPROVAL_HEADER,
9
- type AppFolder,
10
10
  type AppNotifications,
11
11
  type Change,
12
12
  type HostContext,
@@ -17,8 +17,8 @@ import {
17
17
  type RpcResponse,
18
18
  type ShareItem,
19
19
  } from "./protocol";
20
-
21
20
  import type { Listener, Teacup } from "./teacup";
21
+ import { teacupFrom } from "./teacup-above";
22
22
 
23
23
  export type { Teacup } from "./teacup";
24
24
 
@@ -62,8 +62,7 @@ const TEACUP = OUTSIDE ? teacupAbove() : location.origin;
62
62
  * is served by `teacup dev` on port 8791).
63
63
  */
64
64
  function teacupAbove() {
65
- const above = location.ancestorOrigins?.[0];
66
- return above === "http://localhost:8790" || above === "https://staging.kiit.fyi" ? above : "https://kiit.fyi";
65
+ return teacupFrom(location.ancestorOrigins?.[0], typeof window === "undefined" ? "" : window.name);
67
66
  }
68
67
 
69
68
  /** Whether something takes typing: a text box, a text area, or anything editable (not a checkbox or a button). */
@@ -157,6 +156,8 @@ export async function connect(appInfo: { name: string; version?: string }, timeo
157
156
  addEventListener("focusin", (e) => typesIn(e.target as Element) && say(true));
158
157
  addEventListener("focusout", () => setTimeout(() => say(typesIn(document.activeElement)), 120));
159
158
  }
159
+ // A pinch inside an app never zooms all of Teacup on a phone or iPad; the app still gets the touches (no-zoom.ts).
160
+ if (inside) stopPageZoom();
160
161
  // Teacup's own keys reach it from inside an app too, unless the app used them: ⌘K (Ctrl+K) for the quick ask, and
161
162
  // Escape, which closes it.
162
163
  if (inside && !OUTSIDE)
@@ -167,6 +168,19 @@ export async function connect(appInfo: { name: string; version?: string }, timeo
167
168
  if (quick || e.key === "Escape") notify("teacup/key", { key: quick ? "quick-ask" : "escape" });
168
169
  });
169
170
  const appId = result?.app?.id ?? null;
171
+ // A link from one of Teacup's own apps to Teacup's own pages (the developer pages, another app) goes to Teacup,
172
+ // which opens it in a window, rather than taking the place of Teacup (on an installed iPhone, with no way back).
173
+ if (inside && !OUTSIDE && appId)
174
+ addEventListener("click", (e) => {
175
+ const a = (e.target as Element | null)?.closest?.("a[href]") as HTMLAnchorElement | null;
176
+ if (!a || e.defaultPrevented || a.hasAttribute("download")) return;
177
+ const u = new URL(a.href);
178
+ // Other sites open as they always have (a new tab; on an iPhone, Safari's own sheet with Done).
179
+ if (u.origin !== location.origin) return;
180
+ if (a.target !== "_blank" && u.pathname.startsWith(`/apps/${appId}/`)) return;
181
+ e.preventDefault();
182
+ void request("teacup/open-link", { url: a.href }).catch(() => {});
183
+ });
170
184
  // The app's menu shortcuts, pressed inside its window, go up to Teacup, which runs the item as if it were chosen.
171
185
  const shortcuts = new Set(result?.shortcuts ?? []);
172
186
  const onKey = (e: KeyboardEvent) => {
@@ -21,6 +21,10 @@
21
21
  body {
22
22
  padding-bottom: var(--teacup-bottom-room, 0px);
23
23
  }
24
+ /* For a page whose own box scrolls (not the page itself): its end clear of the Home pill too. */
25
+ .tc-bottom-room {
26
+ padding-bottom: var(--teacup-bottom-room, 0px);
27
+ }
24
28
  :root {
25
29
  --teacup-screen: calc(100dvh - var(--teacup-bottom-room, 0px));
26
30
  }
@@ -131,6 +131,8 @@ export interface Manifest {
131
131
  version: string;
132
132
  /** One sentence, shown in the store and to Claude. */
133
133
  description: string;
134
+ /** A few words under its name in lists ("Notes you write together"); left out, the start of the description. */
135
+ subtitle?: string;
134
136
  /** kit: built in apps/<id>/ with the kit. framed: an older page Teacup shows as it is. */
135
137
  kind: "kit" | "framed";
136
138
  /** Where it opens on Teacup's site, ending in "/". */
@@ -197,7 +199,6 @@ const COLLECTION = /^[a-z][a-z0-9_]{1,30}$/;
197
199
  const RESERVED = ["teacup", "history", "docs", "system", "files", "mail", "people", "approvals", "messages"];
198
200
  /** Reserved names that are Teacup's own system apps (Files), which only they may use. */
199
201
  const SYSTEM_IDS = ["files"];
200
-
201
202
  /** Fields decision 31 reserves for later (server code Teacup runs, AI, apps without screens). */
202
203
  const NOT_YET = ["server", "ai", "screens"];
203
204
 
@@ -222,6 +223,11 @@ export function validateManifest(m: Partial<Manifest>, folder?: string, opts: {
222
223
  typeof m.description === "string" && m.description.length >= 10 && m.description.length <= 140,
223
224
  "description must be one sentence of 10 to 140 characters",
224
225
  );
226
+ const sub = m.subtitle;
227
+ need(
228
+ sub === undefined || (words(sub, 3, 40) && !/[.!]$/.test(sub)),
229
+ "subtitle must be 3 to 40 characters, no full stop",
230
+ );
225
231
  need(m.kind === "kit" || m.kind === "framed", "kind must be kit or framed");
226
232
  if (opts.outside)
227
233
  need(m.url === `https://${m.id}.backed.cc/v/${m.version}/`, "an outside app lives at its own address on backed.cc");
@@ -0,0 +1,15 @@
1
+ // On a phone or iPad, Teacup never zooms as a whole when pinched, as a phone's own home screen and apps never do
2
+ // (docs/os.md). Safari zooms the whole page on a pinch, even one made inside an app's window, unless the page stops
3
+ // its gesture; the viewport's maximum-scale stops it elsewhere. Shared by the shell and the kit, so a pinch anywhere
4
+ // is caught. An app that wants a pinch for its own content (a photo, a map) still gets every touch and pointer
5
+ // event, and handles the pinch itself; only the zoom of all of Teacup is stopped. Laptops keep their own zoom.
6
+
7
+ /** A touch-first device: no mouse or trackpad that hovers (core/form.ts draws the same line). */
8
+ const touchFirst = () => !matchMedia("(pointer: fine) and (hover: hover)").matches;
9
+
10
+ export function stopPageZoom(doc: Document = document) {
11
+ if (typeof matchMedia === "undefined" || !touchFirst()) return;
12
+ // Safari's own pinch events: stopped, so the page doesn't zoom.
13
+ for (const type of ["gesturestart", "gesturechange", "gestureend"])
14
+ doc.addEventListener(type, (e) => e.preventDefault(), { passive: false });
15
+ }
@@ -40,8 +40,12 @@ export interface HostContext {
40
40
  /** Each person's settings for Teacup, kept on the server (docs/os.md, decision 24). */
41
41
  export interface Prefs {
42
42
  appearance?: Appearance;
43
+ /** The desktop's wallpaper: one of Teacup's (wallpapers.ts) or "photo", your own desktop photo. */
43
44
  wallpaper?: string;
44
- text?: "standard" | "large" | "larger";
45
+ /** The phone's wallpaper; until it's chosen, the desktop's if that's one of Teacup's, else Teacup's default. */
46
+ wallpaperPhone?: string;
47
+ /** Text size: "auto" (or unset) follows the device's own where the browser says it; the rest are fixed. */
48
+ text?: "auto" | "standard" | "large" | "larger";
45
49
  /** The widgets on the home screen, in order: "today" or "<app>.<widget>". */
46
50
  widgets?: string[];
47
51
  /** Which page of the phone's home screen each widget is on (the first, 0, unless moved). */
@@ -54,8 +58,10 @@ export interface Prefs {
54
58
  notify?: Record<string, boolean>;
55
59
  /** Each app's own settings, by app and setting id. */
56
60
  apps?: Record<string, Record<string, boolean | string>>;
57
- /** When your own wallpaper photo last changed, so every device fetches the new one. */
61
+ /** When your own desktop photo last changed, so every device fetches the new one. */
58
62
  wallpaperAt?: number;
63
+ /** When your own phone photo last changed. */
64
+ wallpaperPhoneAt?: number;
59
65
  /** Today's weather switched off: nothing about it is fetched. Shown unless this is true. */
60
66
  weatherHidden?: boolean;
61
67
  /** The town Today's weather is for; where you are (Cloudflare's estimate) when there's none, or null. */
@@ -176,7 +182,7 @@ export interface HomeScreen {
176
182
  /** The folder it's in, if any. */
177
183
  folder?: string;
178
184
  /** Its icon: the tile's background, and a picture of the mark on it (a data: address, or the app's own). */
179
- icon: { bg: string; picture: string };
185
+ icon: { bg: string; picture: string; full?: boolean };
180
186
  }[];
181
187
  /** Your folders of apps, in the order they sit. */
182
188
  folders: AppFolder[];
@@ -0,0 +1,11 @@
1
+ // Which Teacup an outside app is inside (kit/src/client.ts): kiit.fyi, its staging copy, or Teacup on the maker's
2
+ // own computer. Kept apart from the client so it can be tested without a browser.
3
+
4
+ /**
5
+ * Which of the three Teacups an outside app is in, from the browser's word for the page above it, or (Firefox has no
6
+ * `ancestorOrigins`) the frame's name, which the shell sets to `teacup:<its origin>`. Only ever one of the three.
7
+ */
8
+ export function teacupFrom(ancestor: string | undefined, frameName: string): string {
9
+ const above = ancestor ?? /^teacup:(.+)$/.exec(frameName)?.[1];
10
+ return above === "http://localhost:8790" || above === "https://staging.kiit.fyi" ? above : "https://kiit.fyi";
11
+ }
@@ -75,6 +75,54 @@ export const WALLPAPERS: Wallpaper[] = [
75
75
  /** The default, until someone chooses. */
76
76
  export const DEFAULT_WALLPAPER = "sky";
77
77
 
78
+ /** Each layout has its own wallpaper: the desktop's (landscape) and the phone's (portrait). */
79
+ export type PhotoLayout = "desktop" | "phone";
80
+
81
+ /** How long a photo's longest side is kept, for each layout: enough for a large laptop screen, or a phone's. */
82
+ export const PHOTO_EDGE: Record<PhotoLayout, number> = { desktop: 2560, phone: 1300 };
83
+
84
+ /**
85
+ * The wallpaper a layout shows: its own choice; for a phone not yet chosen, the desktop's if that's one of Teacup's
86
+ * own (as it was before each had its own), never the desktop's photo; otherwise Teacup's default. `photoAt` is the
87
+ * version of that layout's photo to fetch, when it shows one.
88
+ */
89
+ export function wallpaperFor(
90
+ prefs: { wallpaper?: string; wallpaperAt?: number; wallpaperPhone?: string; wallpaperPhoneAt?: number },
91
+ layout: PhotoLayout,
92
+ ): { id: string; photoAt: number | null } {
93
+ const ours = (id: string | undefined) => !!id && WALLPAPERS.some((w) => w.id === id);
94
+ const id =
95
+ layout === "desktop"
96
+ ? (prefs.wallpaper ?? DEFAULT_WALLPAPER)
97
+ : (prefs.wallpaperPhone ?? (ours(prefs.wallpaper) ? prefs.wallpaper : undefined) ?? DEFAULT_WALLPAPER);
98
+ if (id !== "photo") return { id: ours(id) ? id : DEFAULT_WALLPAPER, photoAt: null };
99
+ const at = layout === "desktop" ? prefs.wallpaperAt : prefs.wallpaperPhoneAt;
100
+ // Chosen, but that layout has no photo of its own: Teacup's default instead of an empty grey.
101
+ return at ? { id: "photo", photoAt: at } : { id: DEFAULT_WALLPAPER, photoAt: null };
102
+ }
103
+
104
+ /**
105
+ * The part of a picture `w` by `h` that fills a layout's screen shape (16:10 for a desktop, 9:19.5 for a phone),
106
+ * centred as near as it can be on the focus point (fx, fy, each 0 to 1), and the size to draw it at.
107
+ */
108
+ export function photoFrame(w: number, h: number, layout: PhotoLayout, fx = 0.5, fy = 0.5) {
109
+ const aspect = layout === "desktop" ? 16 / 10 : 9 / 19.5;
110
+ const cw = Math.min(w, h * aspect);
111
+ const ch = cw / aspect;
112
+ const clamp = (v: number, max: number) => Math.round(Math.min(Math.max(v, 0), max));
113
+ const sx = clamp(fx * w - cw / 2, w - cw);
114
+ const sy = clamp(fy * h - ch / 2, h - ch);
115
+ const scale = Math.min(1, PHOTO_EDGE[layout] / Math.max(cw, ch));
116
+ return {
117
+ sx,
118
+ sy,
119
+ sw: Math.round(cw),
120
+ sh: Math.round(ch),
121
+ width: Math.round(cw * scale),
122
+ height: Math.round(ch * scale),
123
+ };
124
+ }
125
+
78
126
  /** The CSS that draws them on Teacup's `.wall`, by the page's data-wallpaper and data-theme. */
79
127
  export function wallpapersCss(): string {
80
128
  return WALLPAPERS.map((w) => {
package/kit/system.ts CHANGED
@@ -5,4 +5,11 @@
5
5
  export { DEVICE_FEATURES, GUIDELINES_VERSION, type Manifest } from "./src/manifest";
6
6
  export { mergePrefs } from "./src/prefs";
7
7
  export { type Appearance, type HomeScreen, KIT_VERSION, type Prefs, type WeatherPlace } from "./src/protocol";
8
- export { DEFAULT_WALLPAPER, WALLPAPERS } from "./src/wallpapers";
8
+ export {
9
+ DEFAULT_WALLPAPER,
10
+ PHOTO_EDGE,
11
+ type PhotoLayout,
12
+ photoFrame,
13
+ WALLPAPERS,
14
+ wallpaperFor,
15
+ } from "./src/wallpapers";
package/kit/widgets.css CHANGED
@@ -80,12 +80,12 @@ button.widget,
80
80
  }
81
81
  .w-today .wd-big {
82
82
  font-weight: 300;
83
- font-size: 44px;
83
+ font-size: calc(44px * var(--text-scale, 1));
84
84
  letter-spacing: -0.04em;
85
85
  line-height: 1;
86
86
  }
87
87
  .w-today .wd-sub {
88
- font-size: 15px;
88
+ font-size: calc(15px * var(--text-scale, 1));
89
89
  color: var(--wg-soft);
90
90
  white-space: nowrap;
91
91
  }
@@ -98,7 +98,7 @@ button.widget,
98
98
  align-items: center;
99
99
  gap: 6px;
100
100
  font-weight: 300;
101
- font-size: 26px;
101
+ font-size: calc(26px * var(--text-scale, 1));
102
102
  letter-spacing: -0.02em;
103
103
  line-height: 1;
104
104
  }
@@ -122,7 +122,7 @@ button.widget,
122
122
  stroke: #d8902a;
123
123
  }
124
124
  .w-today .wd-say {
125
- font-size: 13px;
125
+ font-size: calc(13px * var(--text-scale, 1));
126
126
  color: var(--wg-soft);
127
127
  margin-top: 6px;
128
128
  white-space: nowrap;
@@ -136,7 +136,7 @@ button.widget,
136
136
  margin-top: 12px;
137
137
  padding: 8px 0;
138
138
  border-block: 1px solid var(--wg-hair);
139
- font-size: 12px;
139
+ font-size: calc(12px * var(--text-scale, 1));
140
140
  color: var(--wg-soft);
141
141
  }
142
142
  .w-today .wd-hours div {
@@ -150,7 +150,7 @@ button.widget,
150
150
  }
151
151
  .w-today .wd-hours b {
152
152
  font-weight: 400;
153
- font-size: 13px;
153
+ font-size: calc(13px * var(--text-scale, 1));
154
154
  color: var(--wg-ink);
155
155
  }
156
156
  .w-today ul {
@@ -168,7 +168,7 @@ button.widget,
168
168
  grid-template-columns: 8px 1fr;
169
169
  gap: 10px;
170
170
  align-items: baseline;
171
- font-size: 14px;
171
+ font-size: calc(14px * var(--text-scale, 1));
172
172
  }
173
173
  .w-today li span,
174
174
  .w-today .wd-count span {
@@ -203,7 +203,7 @@ button.widget,
203
203
  display: grid;
204
204
  margin-top: auto;
205
205
  padding-top: 8px;
206
- font-size: 12px;
206
+ font-size: calc(12px * var(--text-scale, 1));
207
207
  gap: 7px;
208
208
  grid-template-columns: 7px 1fr;
209
209
  }
@@ -215,10 +215,10 @@ button.widget,
215
215
  margin-top: 4px;
216
216
  }
217
217
  .w-today .wd-sub {
218
- font-size: 12px;
218
+ font-size: calc(12px * var(--text-scale, 1));
219
219
  }
220
220
  .w-today .wd-now {
221
- font-size: 18px;
221
+ font-size: calc(18px * var(--text-scale, 1));
222
222
  gap: 4px;
223
223
  }
224
224
  .w-today .wd-now .wd-sky {
@@ -229,14 +229,14 @@ button.widget,
229
229
  grid-template-columns: repeat(3, 1fr);
230
230
  margin-top: 8px;
231
231
  padding: 6px 0;
232
- font-size: 11px;
232
+ font-size: calc(11px * var(--text-scale, 1));
233
233
  }
234
234
  .w-today .wd-hours .wd-sky {
235
235
  width: 15px;
236
236
  height: 15px;
237
237
  }
238
238
  .w-today .wd-hours b {
239
- font-size: 11px;
239
+ font-size: calc(11px * var(--text-scale, 1));
240
240
  }
241
241
  }
242
242
 
@@ -249,13 +249,13 @@ button.widget,
249
249
  }
250
250
  .w-app .wd-big {
251
251
  font-weight: 300;
252
- font-size: 40px;
252
+ font-size: calc(40px * var(--text-scale, 1));
253
253
  letter-spacing: -0.03em;
254
254
  line-height: 1.1;
255
255
  margin-top: 10px;
256
256
  }
257
257
  .w-app .wd-sub {
258
- font-size: 15px;
258
+ font-size: calc(15px * var(--text-scale, 1));
259
259
  color: var(--wg-soft);
260
260
  margin-top: 2px;
261
261
  }
@@ -281,7 +281,7 @@ button.widget,
281
281
  padding: 0;
282
282
  display: grid;
283
283
  gap: 6px;
284
- font-size: 15px;
284
+ font-size: calc(15px * var(--text-scale, 1));
285
285
  overflow: hidden;
286
286
  }
287
287
  .w-app li {
@@ -317,7 +317,7 @@ button.widget,
317
317
  min-height: 0;
318
318
  }
319
319
  .widget-box .w-today .wd-big {
320
- font-size: 34px;
320
+ font-size: calc(34px * var(--text-scale, 1));
321
321
  }
322
322
  .widget-box .w-today .wd-hours {
323
323
  margin-top: 8px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teacup-os",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The teacup command: make, check, try and upload apps for Teacup (kiit.fyi), a web operating system for individuals and teams.",
5
5
  "type": "module",
6
6
  "bin": {
package/template/app.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "name": "__NAME__",
4
4
  "version": "0.1.0",
5
5
  "description": "__NAME__: say in one sentence what it does and who it's for.",
6
+ "subtitle": "__NAME__ in a few words",
6
7
  "kind": "kit",
7
8
  "url": "/apps/__ID__/",
8
9
  "iconBackground": "linear-gradient(160deg,#ffffff,#e9e6df)",
package/template/icon.svg CHANGED
@@ -1 +1 @@
1
- <svg viewBox="0 0 40 40" aria-hidden="true"><rect x="8" y="8" width="24" height="24" rx="6" fill="none" stroke="#16181b" stroke-width="2.5"/><circle cx="20" cy="20" r="4" fill="#2f6bff"/></svg>
1
+ <svg viewBox="0 0 24 24" fill="none" stroke="#2b2f36" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><!-- Lucide: box --><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>