# DevPossible Start Page — Layout Authoring Guide

- **Audience:** AI coding agents (and humans) generating start page layout files.
- **Live version:** <https://devpossible.com/start/skill/>
- **Raw file:** <https://devpossible.com/start/skill/start-page-authoring.md>
- **Applies to:** layout format **version 3**

You are reading the complete specification for the files the DevPossible Start Page
(<https://devpossible.com/start/>) imports and exports. With this document alone you can
author a single page or a whole set of pages, without reading the application source.

---

## 1. What you are producing

A single text file describing one or more **pages**. Each page has settings (background,
grid) and a list of **widgets** placed on a 12-column grid.

The user loads your file at **Start Page → Settings (`S`) → Data → Import**, picks a
`.ton` or `.json` file, reviews a preview, and confirms. Imported pages keep their names
and are **added** to whatever the user already has. If a page name matches an existing
page, the preview asks the user — per page — whether to **overwrite** that page or
**keep both**.

| Question | Answer |
| --- | --- |
| File extension | `.ton` (preferred) or `.json` |
| Syntax | JSON — valid JSON is valid TON, and that is what the app itself writes |
| Encoding | UTF-8, no BOM |
| Multiple pages per file | Yes — put them all in the `pages` array |
| Overwrites user data? | Only if the user chooses **Overwrite** for a page whose name already exists; otherwise pages are appended |
| Where do the files live? | Anywhere. The user picks the file from disk |

> **TON note.** TON (Text Object Notation, <https://tonspec.com>) is a superset that also
> allows comments and trailing commas. The Start Page writes plain JSON-shaped TON, and
> that is what you should emit. Do not use `=` separators or other TON extensions unless
> you have a reason to — plain JSON round-trips through both parsers.

---

## 2. Minimum viable file

```json
{
  "version": 3,
  "includes": {
    "siteSettings": false,
    "layout": true,
    "widgetDisplay": true,
    "widgetConfig": true,
    "userData": false
  },
  "pages": [
    {
      "name": "Home",
      "settings": {
        "columns": 12,
        "designedColumns": 12,
        "adaptiveGrid": false,
        "gridGap": 8,
        "background": { "type": "solid", "solid": "#0d1117" }
      },
      "widgets": [
        {
          "type": "search",
          "position": { "x": 3, "y": 0, "w": 6, "h": 1 },
          "display": { "displayMode": "full-frame-hide" },
          "config": { "placeholder": "Search the web..." }
        },
        {
          "type": "clock",
          "position": { "x": 3, "y": 1, "w": 2, "h": 2 },
          "display": { "displayMode": "full-header-hide" },
          "config": { "format24h": false, "showSeconds": false, "showDate": true }
        }
      ]
    }
  ]
}
```

---

## 3. Top-level object

| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `version` | number | **yes** | **Always `3`.** See the warning below |
| `exportedAt` | string | no | ISO-8601 timestamp, informational only |
| `includes` | object | **yes in practice** | Declares what the file carries — drives the import dialog |
| `siteSettings` | object | no | Global theme/appearance. Only applied if the user ticks the box |
| `pages` | array | **yes** | One or more page objects |

### ⚠️ Always set `version: 3`

If `version` is missing, or is `1` or `2`, the importer runs migrations on your data:

- **v1 → v2** splits `displayMode` / `hideScrollbar` out of `config` into `display`.
- **v2 → v3** rescales every widget: **`y` and `h` are multiplied by 0.5**, and `x`/`w`
  are scaled if `settings.columns` is not 12.

A file written to this spec and tagged `version: 2` will come out with **half the intended
height**. Tag it `3`.

### `includes` — and the userData trap

```json
"includes": {
  "siteSettings": false,
  "layout": true,
  "widgetDisplay": true,
  "widgetConfig": true,
  "userData": false
}
```

The import dialog shows a checkbox per category, and only shows a checkbox when the file
declares that category:

| Category | Shown when | Default state | Effect if unchecked |
| --- | --- | --- | --- |
| Widget display | always | **on** | `display` objects dropped, widgets render with frames/headers |
| Widget config | `includes.widgetConfig` is true **or** any widget has a non-empty `config` | on | `config` dropped, widgets fall back to their built-in defaults |
| Site settings | `siteSettings` object present **or** `includes.siteSettings` true | **off** | Theme/accent/opacity not applied |
| User data | **only** when `includes.userData` is `true` | **off** | `userData` never written |

> **Critical:** widget `userData` (notes text, todo items, bookmark lists) is **only
> importable if you set `includes.userData: true`**. Unlike `config`, it is *not*
> auto-detected from the widget bodies. If you ship bookmarks or todos, set that flag —
> and tell the user to tick the "User data" checkbox, since it defaults to off.

### `siteSettings` (optional, global)

Applies to the whole app, not per page. Only written if the user opts in at import.

```json
"siteSettings": {
  "theme": "dark",
  "accent": "#47b2e4",
  "widgetOpacity": 100,
  "widgetRadius": 8,
  "gridGap": 8
}
```

| Key | Type | Values | Default |
| --- | --- | --- | --- |
| `theme` | string | `dark`, `light` | `dark` |
| `accent` | string | any CSS hex color | `#47b2e4` |
| `widgetOpacity` | number | `0`–`100` | `100` |
| `widgetRadius` | number | px | `8` |
| `gridGap` | number | px | `8` |

---

## 4. Page object

```json
{
  "name": "Dashboard",
  "settings": { ... },
  "widgets": [ ... ]
}
```

| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | Shown in the page switcher. Kept as-is on import. If it matches an existing page, the user picks overwrite or keep both; keeping both adds a ` (Imported)` suffix (` (Imported 2)`, etc. for further collisions). |
| `settings` | object | yes | See below |
| `widgets` | array | yes | May be empty |

Do **not** include a page `id` — the app generates one. Do not include `activePage`; the
first imported page is activated automatically.

### Page `settings`

| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `columns` | number | `12` | Grid columns the layout is drawn on. **Use 12.** |
| `designedColumns` | number | `12` | Records the column count the layout was designed for |
| `adaptiveGrid` | boolean | `true` when absent | `false` = your column count is honoured exactly. **Set `false` for pixel-faithful layouts.** |
| `gridGap` | number | `8` | Gap in px between grid cells; also used as the grid margin |
| `background` | object | — | See below |
| `theme`, `widgetOpacity`, `borderRadius` | — | — | Accepted and preserved, but **currently inert** — appearance comes from `siteSettings`. Safe to include for round-tripping; do not rely on them |

**`adaptiveGrid: true`** lets the app recompute the column count from the viewport width
(cells stay between 60–150px wide, columns between 4 and 24). Widget `x`/`w` values then
mean less than the relative arrangement. **`adaptiveGrid: false`** keeps your grid exactly
as authored — use it whenever the layout matters.

Regardless of the setting, on narrow/portrait viewports the app **reclaims empty left
columns**: if every widget starts at `x >= 3`, the whole layout shifts left by 3. Design
with content starting near `x: 0` if you want predictable phone rendering, or leave the
left margin intentionally and accept the shift.

### Background

```json
"background": {
  "type": "image",
  "solid": "#0d1117",
  "gradient": { "start": "#0d1117", "end": "#1a1f2e", "angle": 135 },
  "image": {
    "url": "./wallpapers/optimized/Texture 3.jpg",
    "blur": 0,
    "opacity": 100,
    "size": "cover",
    "autoDarken": true,
    "showArtworkInfo": true,
    "barFill": {
      "mode": "none",
      "color": "#0d1117",
      "gradientStart": "#0d1117",
      "gradientEnd": "#1a1f2e"
    }
  }
}
```

| Key | Values |
| --- | --- |
| `type` | `solid`, `gradient`, `image` |
| `solid` | hex color (used when `type: "solid"`) |
| `gradient.angle` | `45`, `90`, `135`, `180` (any number works) |
| `image.url` | a bundled wallpaper path, any `https://` image URL, or the special value `artic:random` (random artwork from the Art Institute of Chicago) |
| `image.size` | `cover`, `contain`, `100% auto` (fill width), `auto 100%` (fill height), `auto` |
| `image.blur` | px blur, `0` = none |
| `image.opacity` | `0`–`100` |
| `image.autoDarken` | boolean — darkens the image so widget text stays readable |
| `image.showArtworkInfo` | boolean — only meaningful for `artic:random` |
| `image.barFill.mode` | `none`, `color`, `gradient-h`, `gradient-v`, `gradient-radial`, `blur` — fills the letterbox bars when the image does not cover the screen |

Keep the sibling keys (`solid`, `gradient`, `image`) present even when unused; the user can
then switch background type in the UI without losing your values.

**Bundled wallpapers** (`./wallpapers/optimized/<name>.jpg`):

`Chipped - Bottom Logo`, `Chipped and Burnt`, `Chipped and Coded`, `Chipped and Traced`,
`Chipped Texture - Bottom Logo`, `Chipped`, `Circle Storm`, `Color Field`, `Curvy Storm`,
`Diamond Storm - Circuit`, `Hacker 1`–`Hacker 5`, `Lightning`, `Storm 1`, `Storm 2`,
`Tech - Diffused`, `Tech - Textured`, `Tech`, `Texture 1`–`Texture 4`, `VanDegraaff`,
`Waves`

---

## 5. Widget object

```json
{
  "type": "rss",
  "position": { "x": 4, "y": 2, "w": 2, "h": 5 },
  "display": { "displayMode": "full-frame-hide", "hideScrollbar": true },
  "config": { "feedUrl": "https://hnrss.org/frontpage", "maxItems": 5 },
  "userData": null
}
```

| Key | Type | Required | Notes |
| --- | --- | --- | --- |
| `type` | string | yes | Must match a type from the catalog in §7. **Unknown types are silently skipped** |
| `position` | object | yes | `{x, y, w, h}` in grid units. Flat `x`/`y`/`w`/`h` keys on the widget also work |
| `display` | object | no | Frame/header behaviour — see §6 |
| `config` | object | no | Type-specific settings — see §7 |
| `userData` | any | no | Only for `notes`, `bookmarks`, `todo` — see §8 |

Do **not** include a widget `id`; the app generates one.

### Grid rules

- Origin is top-left: `x: 0, y: 0`.
- `x` is the column (`0`–`11`), `w` the column span. Keep **`x + w <= 12`**.
- `y` is the row, `h` the row span. Rows are unbounded — the page scrolls.
- A cell is roughly **100 × 70 px** on a 12-column desktop layout. So `w: 3, h: 3` ≈ 300 × 210 px.
- Widgets must not overlap. Overlaps are resolved by the grid engine by pushing widgets
  down, which will not match your intent.
- Respect each widget's `min`/`max` size from §7. They are enforced on resize, and a widget
  smaller than its minimum will look broken.

### Layout heuristics that produce good pages

1. Sketch on a 12-column grid, full-width rows: e.g. `3 | 6 | 3`, or `4 | 4 | 4`.
2. Put `search` at the top, full or near-full width, `h: 1`–`2`.
3. Give list widgets (`rss`, `hacker-news`, `todo`, `github-activity`) real height — `h: 5`+.
4. Give `clock`, `weather`, `calendar` compact tiles — `w: 1`–`2`, `h: 2`–`4`.
5. Leave no vertical gaps: a row of `h: 3` widgets starting at `y: 2` means the next row starts at `y: 5`.
6. For a chrome-free "dashboard" look, set `displayMode: "full-frame-hide"` on everything.

---

## 6. `display` — frame and scrollbar

```json
"display": { "displayMode": "full-frame-hide", "hideScrollbar": true }
```

| `displayMode` | Behaviour |
| --- | --- |
| `normal` | Header and frame always visible (default) |
| `hide-header` | Header hidden, appears on hover |
| `hide-frame` | Frame and header hidden, appear on hover |
| `full-header-hide` | Header hidden except in edit mode |
| `full-frame-hide` | Frame + header hidden except in edit mode — **the clean dashboard look** |
| `full-frame-hide-readonly` | Same as above, and blocks clicks (decorative widgets) |

`hideScrollbar` (boolean, default `false`) hides the scrollbar on scrollable widgets while
keeping the content scrollable. Useful with `rss`, `todo`, `bookmarks`, `hacker-news`.

---

## 7. Widget catalog

28 widget types. `Size` columns are grid units (`w × h`).

| Type | Name | Category | Default | Min | Max | Scrolls |
| --- | --- | --- | --- | --- | --- | --- |
| `artic` | Art Institute | custom | 3×4 | 2×2 | 8×10 | |
| `astronomy` | Astronomy | news | 2×3 | 2×3 | 4×7 | |
| `bookmarks` | Bookmarks | productivity | 4×2 | 1×1 | 8×8 | ✓ |
| `calendar` | Calendar | time | 2×2 | 2×4 | 4×6 | |
| `clock` | Digital Clock | time | 2×2 | 1×1 | 4×5 | |
| `countdown` | Countdown Timer | time | 2×2 | 2×2 | 6×6 | |
| `crypto` | Crypto Prices | news | 2×2 | 2×3 | 4×6 | ✓ |
| `github-activity` | GitHub Activity | developer | 3×3 | 3×3 | 6×8 | ✓ |
| `github-stats` | GitHub Stats | developer | 2×2 | 2×3 | 4×5 | |
| `hacker-news` | Hacker News | developer | 3×3 | 3×3 | 6×8 | ✓ |
| `history` | This Day in History | news | 2×3 | 3×4 | 5×8 | |
| `html-content` | HTML Content | custom | 4×3 | 1×1 | 12×12 | |
| `iframe` | Embed Page | custom | 3×3 | 2×3 | 8×10 | |
| `notes` | Quick Notes | productivity | 2×2 | 2×3 | 6×8 | |
| `npm-stats` | NPM Stats | developer | 2×2 | 2×3 | 4×6 | ✓ |
| `pomodoro` | Pomodoro Timer | productivity | 2×2 | 2×2 | 4×6 | |
| `quote` | Quote of the Day | news | 3×2 | 3×2 | 7×6 | |
| `rss` | RSS Feed | news | 2×3 | 2×3 | 5×7 | ✓ |
| `search` | Search | productivity | 6×1 | 4×1 | 12×3 | |
| `simple-panel` | Simple Panel | custom | 3×3 | 1×1 | 12×10 | |
| `stock-market` | Stock Market | news | 3×4 | 2×2 | 6×8 | |
| `system-info` | System Info | developer | 2×3 | 2×3 | 5×8 | ✓ |
| `system-status` | Service Status | developer | 2×3 | 2×3 | 4×6 | ✓ |
| `tab-frames` | Tab Frames | custom | 4×4 | 3×4 | 9×10 | |
| `todo` | Todo List | productivity | 2×2 | 2×3 | 4×7 | ✓ |
| `tool-frames` | Tool Frames | productivity | 4×4 | 3×4 | 9×10 | |
| `weather` | Weather | news | 2×2 | 1×1 | 4×6 | |
| `world-clocks` | World Clocks | time | 2×2 | 2×3 | 4×6 | ✓ |

Below, each widget's `config` keys. **Omit a key to accept its default.** Values shown as
`a | b | c` are the only accepted values. Use the native type shown (numbers as numbers).

### Time & date

**`clock` — Digital Clock**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `timezone` | string | `local` | `local`, or an IANA zone: `America/New_York`, `America/Los_Angeles`, `America/Chicago`, `Europe/London`, `Europe/Paris`, `Asia/Tokyo`, `Asia/Shanghai`, `Australia/Sydney`, `UTC` |
| `format24h` | boolean | `true` | |
| `showSeconds` | boolean | `true` | |
| `showDate` | boolean | `true` | |
| `timeFontSize` | number | `1.8` | rem: `1.0`, `1.4`, `1.8`, `2.5`, `3.5`, `5.0` |
| `timeColor` | string | `""` | hex, `""` = theme default |
| `dateFontSize` | number | `0.8` | rem: `0.6`, `0.8`, `1.0`, `1.2`, `1.5` |
| `dateColor` | string | `""` | hex |

**`world-clocks` — World Clocks**

| Key | Type | Default |
| --- | --- | --- |
| `timezones` | string | `"America/New_York,Europe/London,Asia/Tokyo,Australia/Sydney"` — comma-separated IANA zones |
| `format24h` | boolean | `true` |

**`calendar` — Calendar**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `weekStartsOn` | string | `sunday` | `sunday`, `monday` |
| `showWeekNumbers` | boolean | `false` | |
| `highlightWeekends` | boolean | `true` | |
| `showAdjacentMonths` | boolean | `true` | |

**`countdown` — Countdown Timer**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `eventName` | string | `"New Year"` | |
| `targetDate` | string | next Jan 1 | `YYYY-MM-DD` |
| `targetTime` | string | `"00:00"` | `HH:MM` |
| `timezone` | string | `local` | same list as `clock` |
| `displayFormat` | string | `daysOnly` | `detailed`, `compact`, `daysOnly`, `largestUnit` |
| `showUnits` | object | `{years:false, months:false, days:true, hours:true, minutes:true, seconds:true}` | all booleans |
| `showLabels` | string | `full` | `full`, `short`, `none` |
| `eventNamePosition` | string | `above` | `above`, `below`, `left`, `right`, `none` |
| `showTargetDate` | boolean | `true` | |
| `completedBehavior` | string | `message` | `message`, `elapsed`, `hide` |
| `completedMessage` | string | `"Event Started!"` | |
| `recurring` | boolean | `false` | |
| `recurringInterval` | string | `yearly` | `yearly`, `monthly`, `weekly`, `daily` |
| `linkUrl` | string | `""` | click target |
| `primaryFontSize` | number | `2.0` | `1.5`, `2.0`, `2.5`, `3.5`, `5.0` |
| `secondaryFontSize` | number | `0.85` | `0.7`, `0.85`, `1.0`, `1.2` |
| `primaryColor` / `secondaryColor` | string | `""` | hex |

### Productivity

**`search` — Search**

| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `enabledEngines` | string[] | `["google","duckduckgo","bing","github","stackoverflow","npm","mdn"]` | **First entry is the default engine.** Ids below |
| `multiSearch` | boolean | `false` | Opens a tab per engine on search |
| `placeholder` | string | `"Search the web..."` | Supports macros |
| `extraTerms` | string | `""` | Appended to every query |
| `requiredTerms` | string | `""` | Emitted as `+term` where supported |
| `negativeTerms` | string | `""` | Emitted as `-term` where supported |

Macros usable in `placeholder`, `extraTerms`, `requiredTerms`, `negativeTerms`:
`{YEAR}`, `{LASTYEAR}`, `{MONTH}`, `{DAY}`, `{DATE}` (`YYYY-MM-DD`).

Engine ids — *general:* `google`, `duckduckgo`, `bing`, `brave`, `ecosia`, `startpage`,
`yahoo`, `yandex`; *developer:* `github`, `stackoverflow`, `npm`, `mdn`, `devdocs`,
`caniuse`, `pypi`, `nuget`, `cratesio`, `dockerhub`; *learning:* `mslearn`, `googledev`,
`awsdocs`, `w3schools`, `coursera`, `udemy`; *reference:* `wikipedia`, `wolframalpha`,
`arxiv`, `scholar`; *social:* `twitter`, `reddit`, `linkedin`, `mastodon`, `bluesky`;
*entertainment:* `youtube`, `twitch`, `spotify`, `soundcloud`, `imdb`, `rottentomatoes`,
`letterboxd`; *anime:* `myanimelist`, `anilist`, `crunchyroll`, `mangadex`, `kitsu`;
*shopping:* `amazon`, `ebay`, `aliexpress`; *images:* `googleimages`, `unsplash`, `pexels`,
`flickr`, `pinterest`, `deviantart`, `artstation`, `dribbble`; *maps:* `googlemaps`,
`openstreetmap`, `tripadvisor`; *news:* `googlenews`, `hackernews`, `techmeme`.

**`bookmarks` — Bookmarks**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `viewMode` | string | `card` | `card`, `compact`, `vertical`, `icon-grid` |
| `columns` | number | `3` | `2`–`6` |
| `iconPlacement` | string | `top` | `top`, `left`, `bottom`, `right`, `none`, `icon-only` |

Bookmark entries live in `userData`, not `config` — see §8.

**`todo` — Todo List**

| Key | Type | Default |
| --- | --- | --- |
| `showCompleted` | boolean | `true` |

Task list lives in `userData` — see §8.

**`notes` — Quick Notes**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `fontSize` | number | `14` | `12`, `14`, `16`, `18` |

Note text lives in `userData` — see §8.

**`pomodoro` — Pomodoro Timer**

| Key | Type | Default | Range |
| --- | --- | --- | --- |
| `workDuration` | number | `25` | 1–120 minutes |
| `shortBreak` | number | `5` | 1–30 |
| `longBreak` | number | `15` | 1–60 |
| `sessionsBeforeLongBreak` | number | `4` | 2–6 |
| `autoStartBreaks` | boolean | `false` | |
| `autoStartWork` | boolean | `false` | |
| `soundType` | string | `chime` | `none`, `beep`, `chime`, `bell`, `digital` |

**`tool-frames` — Tool Frames** (embeds DevPossible tools in tabs)

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `tabDisplay` | string | `icon-text` | `icon-text`, `icon-only`, `text-only` |
| `tools` | object[] | `[{id:"json-validate",name:"JSON Validator"},{id:"text-base64",name:"Base64"}]` | `{ id, name }` |
| `activeTab` | number | `0` | index into `tools` |

`id` must match a tool in <https://devpossible.com/tools/tools-manifest.json> (221 tools).
Common ids: `json-validate`, `json-query`, `json-to-yaml`, `xml-validate`, `csv-to-json`,
`yaml-validate`, `ton-validate`, `text-base64`, `text-hash`, `text-diff`, `regex-tester`,
`jwt-decode`, `uuid-generator`, `unix-converter`, `timezone-converter`, `cron-builder`,
`color-converter`, `qr-generator`, `network-dns-lookup`, `network-ip-info`, `subnet-calculator`,
`css-gradient`, `css-flexbox`, `contrast-checker`, `scientific-calculator`, `whiteboard`.
Fetch the manifest if you need one that is not listed.

### Developer

**`github-stats`** — `username` (string, default `"torvalds"`).

**`github-activity`**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `username` | string | `"torvalds"` | |
| `maxEvents` | number | `10` | `5`, `10`, `15`, `20` |

**`hacker-news`**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `storyType` | string | `top` | `top`, `new`, `best`, `ask`, `show`, `job` |
| `storyCount` | number | `10` | `5`, `10`, `15`, `20` |

**`npm-stats`** — `packages` (string, comma-separated, default `"react,vue,angular,svelte"`).

**`system-status` — Service Status**

`services`: comma-separated string, default `"github,npm,cloudflare,vercel"`. Available ids:
`github`, `gitlab`, `bitbucket`, `circleci`, `aws`, `azure`, `gcp`, `digitalocean`, `vercel`,
`netlify`, `cloudflare`, `heroku`, `render`, `npm`, `docker`, `supabase`, `mongodb`,
`planetscale`, `discord`, `slack`, `zoom`, `twilio`, `atlassian`, `notion`, `figma`, `linear`,
`datadog`, `sentry`, `pagerduty`, `auth0`, `stripe`, `openai`.

**`system-info` — System Info**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `enabledItems` | object | see below | boolean per item |
| `groupOrder` | string[] | `["network","browser","device","hardware","connection"]` | |
| `showHeaders` | boolean | `true` | |
| `displayStyle` | string | `compact` | `compact`, `cards`, `detailed` |
| `refreshInterval` | number | `30` | `15`, `30`, `60` minutes |

`enabledItems` keys by group — *network:* `ip`, `location`, `isp`, `timezone`; *browser:*
`browser`, `language`, `cookies`, `doNotTrack`; *device:* `os`, `screen`, `window`,
`pixelRatio`, `touch`, `cpuCores`; *hardware:* `gpu`, `memory`, `battery` (off by default);
*connection:* `status`, `type`, `speed`.

### News & info

**`rss` — RSS Feed**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `feedUrl` | string | `"https://hnrss.org/frontpage"` | any RSS/Atom URL |
| `maxItems` | number | `10` | `1`–`5`, `10`, `15`, `20` |
| `showDescription` | boolean | `false` | |
| `refreshInterval` | number | `15` | `5`, `15`, `30`, `60` minutes |

Known-good feeds: `https://hnrss.org/frontpage`, `https://dev.to/feed`,
`https://lobste.rs/rss`, `https://css-tricks.com/feed/`,
`https://feeds.arstechnica.com/arstechnica/index`, `https://www.theverge.com/rss/index.xml`,
`https://techcrunch.com/feed/`, `https://www.wired.com/feed/rss`, `https://github.blog/feed/`,
`https://blog.cloudflare.com/rss/`, `https://hacks.mozilla.org/feed/`,
`https://web.dev/feed.xml`, `https://www.reddit.com/r/programming/.rss`,
`https://krebsonsecurity.com/feed/`.

**`weather` — Weather**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `units` | string | `celsius` | `celsius`, `fahrenheit` |
| `locationType` | string | `auto` | `auto` (GPS), `manual` |
| `locationQuery` | string | `""` | city/postcode, used when `manual` |
| `location` | object\|null | `null` | `{ lat, lon }` — resolved coordinates |
| `locationName` | string | `""` | display label, e.g. `"Montevallo, Alabama"` |

For a shareable page prefer `locationType: "auto"`. For a fixed city, set `manual` plus
`locationQuery`, and optionally `location` + `locationName` to skip the geocoding round trip.

**`astronomy`**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `locationType` | string | `auto` | `auto`, `manual` |
| `latitude` / `longitude` | string | `""` | decimal degrees, used when `manual` |
| `showMoonPhase`, `showSunTimes`, `showDayLength`, `showNextMoonEvents` | boolean | `true` | |

**`crypto` — Crypto Prices**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `coins` | string | `"bitcoin,ethereum,solana"` | comma-separated CoinGecko ids |
| `currency` | string | `usd` | `usd`, `eur`, `gbp`, `jpy`, `cad`, `aud`, `chf`, `cny` |

Known coin ids: `bitcoin`, `ethereum`, `tether`, `binancecoin`, `solana`, `ripple`, `cardano`,
`avalanche`, `dogecoin`, `polkadot`, `chainlink`, `tron`, `litecoin`, `uniswap`, `stellar`,
`monero`, `cosmos`, `hedera`, `filecoin`, `aptos`, `arbitrum`, `optimism`, `vechain`, `aave`,
`algorand`, `fantom`, `injective`, `theta`, `tezos`, `decentraland`, `axie`, `eos`, `maker`,
`pepe`, `sui`, `sei`, `celestia`, `jupiter`.

**`stock-market` — Stock Market** (TradingView embeds)

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `widgetType` | string | `market-overview` | `market-overview`, `symbol-overview`, `mini-chart`, `ticker`, `ticker-tape` |
| `symbol` | string | `"NASDAQ:AAPL"` | `EXCHANGE:SYMBOL`, used by `mini-chart` |
| `dateRange` | string | `12M` | `1D`, `1M`, `3M`, `12M`, `60M`, `ALL` |
| `showChart` | boolean | `true` | |
| `showSymbolLogo` | boolean | `true` | |

**`quote` — Quote of the Day**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `category` | string | `programming` | `programming`, `motivational`, `science`, `philosophy`, `design`, `business`, `humor` |
| `quoteFontSize` | string | `medium` | `small`, `medium`, `large`, `xlarge` |
| `quoteFontFamily` | string | `serif` | `serif`, `sans`, `mono`, `cursive` |
| `quoteColor` / `authorColor` | string | `""` | hex |
| `authorFontSize` | string | `medium` | `small`, `medium`, `large` |
| `showAuthorImage`, `showAuthorDates`, `showNavArrows`, `clickToOpenWikipedia` | boolean | `false` | |

**`history` — This Day in History**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `showEvents` | boolean | `true` | |
| `showBirths` | boolean | `true` | |
| `showDeaths` | boolean | `false` | |
| `maxItems` | number | `10` | `5`, `10`, `15`, `20` |
| `showYear` | boolean | `true` | |

### Custom / layout

**`html-content` — HTML Content**

| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `html` | string | `""` | Raw HTML, may include a `<style>` block |
| `allowOverflow` | boolean | `true` | Lets popovers/banners paint past the widget edge (clipped in edit mode) |
| `padding` | boolean | `true` | Inner padding |
| `runScripts` | boolean | `false` | **Leave `false`.** Scripts in an imported page can read the user's saved data |

**`simple-panel` — Simple Panel** (image + title + text, optionally a link)

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `image` | string | `""` | image URL |
| `imagePosition` | string | `top` | `top`, `bottom`, `left`, `right`, `background` |
| `imageSize` | number | `40` | 1–100 (% of panel; ignored for `background`) |
| `imageFit` | string | `contain` | `contain`, `cover`, `fill`, `none` |
| `title` | string | `"Simple Panel"` | |
| `text` | string | `""` | |
| `hoverText` | string | `""` | tooltip |
| `fontFamily` | string | `inherit` | `inherit`, `system-ui, sans-serif`, `Georgia, serif`, `"Courier New", monospace`, `"Segoe UI", Roboto, sans-serif`, `Impact, fantasy` |
| `fontSize` | number | `14` | 8–96 px |
| `textColor` | string | `""` | hex |
| `textAlign` | string | `center` | `left`, `center`, `right` |
| `link` | string | `""` | makes the whole panel clickable |
| `linkNewTab` | boolean | `true` | |

**`iframe` — Embed Page**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `url` | string | `""` | page to embed — the site must allow framing |
| `title` | string | `"Embedded Page"` | |
| `refreshInterval` | number | `0` | `0` (never), `5`, `15`, `30`, `60` minutes |

**`tab-frames` — Tab Frames**

| Key | Type | Default | Notes |
| --- | --- | --- | --- |
| `tabs` | object[] | GitHub + Dev.to | `{ name, url, icon }` |
| `activeTab` | number | `0` | index into `tabs` |
| `refreshInterval` | number | `0` | `0`, `5`, `15`, `30`, `60` minutes |

`icon` is either a Bootstrap Icons class (`bi-github`) or an image URL
(`https://www.google.com/s2/favicons?domain=example.com&sz=64`).

**`artic` — Art Institute of Chicago**

| Key | Type | Default | Values |
| --- | --- | --- | --- |
| `mode` | string | `random` | `random`, `search`, `specific` |
| `searchQuery` | string | `""` | used when `mode: "search"` |
| `artworkId` | number\|null | `null` | used when `mode: "specific"` |
| `showInfo` | boolean | `true` | overlay with title/artist |
| `refreshInterval` | number | `0` | `0`, `5`, `15`, `30`, `60` minutes |

---

## 8. `userData` — notes, todos, bookmarks

Only three widget types carry user data. It is stored per widget, outside `config`, and is
**only imported when `includes.userData` is `true` and the user ticks "User data"**.

**`bookmarks`** — array of bookmark objects:

```json
"userData": [
  { "name": "GitHub", "url": "https://github.com", "icon": "bi-github" },
  { "name": "Claude", "url": "https://claude.ai/new",
    "icon": "https://www.google.com/s2/favicons?domain=claude.ai&sz=64" }
]
```

`icon` is a Bootstrap Icons class (`bi-github`, `bi-google`, `bi-terminal`, `bi-book`,
`bi-box-seam`, `bi-newspaper`, `bi-code-slash`, `bi-cloud`, `bi-database`, `bi-globe`, …) or
an image/favicon URL. `https://www.google.com/s2/favicons?domain=<host>&sz=64` works well
for arbitrary sites.

**`todo`** — array of task objects:

```json
"userData": [
  { "text": "Ship the release", "completed": false, "createdAt": "2026-01-05T05:33:37.061Z" }
]
```

`createdAt` is an ISO-8601 string; include it, it drives ordering.

**`notes`** — the note body as a plain string:

```json
"userData": "Scratch pad\n- first line\n- second line"
```

> Known quirk: imported note text is stored JSON-encoded, so it appears in the widget
> wrapped in quotes on first load. Editing the note clears this. Prefer shipping `notes`
> widgets empty (omit `userData`) unless the seeded text matters.

---

## 9. Complete example — a two-page developer set

```json
{
  "version": 3,
  "exportedAt": "2026-08-08T00:00:00.000Z",
  "includes": {
    "siteSettings": true,
    "layout": true,
    "widgetDisplay": true,
    "widgetConfig": true,
    "userData": true
  },
  "siteSettings": {
    "theme": "dark",
    "accent": "#47b2e4",
    "widgetOpacity": 100,
    "widgetRadius": 8,
    "gridGap": 8
  },
  "pages": [
    {
      "name": "Home",
      "settings": {
        "columns": 12,
        "designedColumns": 12,
        "adaptiveGrid": false,
        "gridGap": 8,
        "background": {
          "type": "image",
          "solid": "#0d1117",
          "gradient": { "start": "#0d1117", "end": "#1a1f2e", "angle": 135 },
          "image": {
            "url": "./wallpapers/optimized/Texture 3.jpg",
            "blur": 0, "opacity": 100, "size": "cover", "autoDarken": true,
            "barFill": { "mode": "none", "color": "#0d1117",
                         "gradientStart": "#0d1117", "gradientEnd": "#1a1f2e" }
          }
        }
      },
      "widgets": [
        {
          "type": "search",
          "position": { "x": 2, "y": 0, "w": 8, "h": 1 },
          "display": { "displayMode": "full-frame-hide" },
          "config": {
            "enabledEngines": ["google", "github", "stackoverflow", "mdn"],
            "placeholder": "Search the web..."
          }
        },
        {
          "type": "clock",
          "position": { "x": 2, "y": 1, "w": 2, "h": 3 },
          "display": { "displayMode": "full-header-hide", "hideScrollbar": true },
          "config": { "format24h": false, "showSeconds": false, "showDate": true,
                      "timeFontSize": 2.5 }
        },
        {
          "type": "weather",
          "position": { "x": 2, "y": 4, "w": 2, "h": 4 },
          "display": { "displayMode": "full-header-hide" },
          "config": { "units": "fahrenheit", "locationType": "auto" }
        },
        {
          "type": "rss",
          "position": { "x": 4, "y": 1, "w": 3, "h": 7 },
          "display": { "displayMode": "full-frame-hide", "hideScrollbar": true },
          "config": { "feedUrl": "https://hnrss.org/frontpage", "maxItems": 10,
                      "showDescription": false, "refreshInterval": 15 }
        },
        {
          "type": "rss",
          "position": { "x": 7, "y": 1, "w": 3, "h": 7 },
          "display": { "displayMode": "full-frame-hide", "hideScrollbar": true },
          "config": { "feedUrl": "https://feeds.arstechnica.com/arstechnica/index",
                      "maxItems": 10, "refreshInterval": 30 }
        },
        {
          "type": "bookmarks",
          "position": { "x": 2, "y": 8, "w": 8, "h": 2 },
          "display": { "displayMode": "full-frame-hide" },
          "config": { "viewMode": "card", "columns": 6, "iconPlacement": "top" },
          "userData": [
            { "name": "GitHub", "url": "https://github.com", "icon": "bi-github" },
            { "name": "MDN", "url": "https://developer.mozilla.org", "icon": "bi-book" },
            { "name": "NPM", "url": "https://npmjs.com", "icon": "bi-box-seam" },
            { "name": "Stack Overflow", "url": "https://stackoverflow.com",
              "icon": "bi-stack-overflow" },
            { "name": "Claude", "url": "https://claude.ai/new",
              "icon": "https://www.google.com/s2/favicons?domain=claude.ai&sz=64" },
            { "name": "Dev.to", "url": "https://dev.to", "icon": "bi-file-earmark-code" }
          ]
        }
      ]
    },
    {
      "name": "Work",
      "settings": {
        "columns": 12,
        "designedColumns": 12,
        "adaptiveGrid": false,
        "gridGap": 8,
        "background": {
          "type": "gradient",
          "solid": "#0d1117",
          "gradient": { "start": "#0d1117", "end": "#1a1f2e", "angle": 135 }
        }
      },
      "widgets": [
        {
          "type": "todo",
          "position": { "x": 0, "y": 0, "w": 3, "h": 7 },
          "display": { "displayMode": "full-frame-hide", "hideScrollbar": true },
          "config": { "showCompleted": true },
          "userData": [
            { "text": "Review pull requests", "completed": false,
              "createdAt": "2026-08-08T09:00:00.000Z" },
            { "text": "Update the changelog", "completed": false,
              "createdAt": "2026-08-08T09:01:00.000Z" }
          ]
        },
        {
          "type": "pomodoro",
          "position": { "x": 3, "y": 0, "w": 3, "h": 3 },
          "display": { "displayMode": "full-frame-hide" },
          "config": { "workDuration": 50, "shortBreak": 10, "longBreak": 20,
                      "soundType": "chime" }
        },
        {
          "type": "github-activity",
          "position": { "x": 6, "y": 0, "w": 3, "h": 7 },
          "display": { "displayMode": "full-frame-hide", "hideScrollbar": true },
          "config": { "username": "octocat", "maxEvents": 15 }
        },
        {
          "type": "system-status",
          "position": { "x": 9, "y": 0, "w": 3, "h": 4 },
          "display": { "displayMode": "full-frame-hide" },
          "config": { "services": "github,npm,cloudflare,vercel,openai" }
        },
        {
          "type": "calendar",
          "position": { "x": 3, "y": 3, "w": 3, "h": 4 },
          "display": { "displayMode": "full-frame-hide" },
          "config": { "weekStartsOn": "monday", "highlightWeekends": true }
        },
        {
          "type": "tool-frames",
          "position": { "x": 9, "y": 4, "w": 3, "h": 4 },
          "display": { "displayMode": "normal" },
          "config": {
            "tabDisplay": "icon-text",
            "tools": [
              { "id": "json-validate", "name": "JSON Validator" },
              { "id": "jwt-decode", "name": "JWT Decoder" },
              { "id": "uuid-generator", "name": "UUID" }
            ]
          }
        }
      ]
    }
  ]
}
```

---

## 10. Validation checklist

Before handing the file over, verify:

- [ ] `version` is `3`.
- [ ] `includes` is present, and `userData: true` **if any widget has `userData`**.
- [ ] Every `type` appears in the §7 catalog (spelling: `github-activity`, `hacker-news`,
      `html-content`, `simple-panel`, `stock-market`, `system-info`, `system-status`,
      `tab-frames`, `tool-frames`, `world-clocks`, `npm-stats`, `github-stats` — all hyphenated).
- [ ] Every widget has a `position` with all four of `x`, `y`, `w`, `h`.
- [ ] `x + w <= 12` for every widget.
- [ ] `w`/`h` are within each widget's min/max from §7.
- [ ] No two widgets on the same page overlap. (Walk the rows: for each pair, they must be
      disjoint on the x-axis or on the y-axis.)
- [ ] No unintended vertical gaps between rows.
- [ ] No page `id`, no widget `id`, no `activePage` key.
- [ ] `userData` is only on `notes`, `bookmarks`, or `todo` widgets.
- [ ] Config values use the exact spellings from §7 (they are matched literally).
- [ ] The file parses as JSON.
- [ ] `runScripts` is not enabled on any `html-content` widget you did not write yourself.

Quick overlap/bounds check you can run:

```bash
node -e '
const d=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
let bad=0;
for (const p of d.pages) {
  const w=p.widgets.map(x=>({t:x.type,...(x.position||x)}));
  w.forEach(a=>{ if(a.x+a.w>12){console.log(`${p.name}: ${a.t} overflows (x=${a.x} w=${a.w})`);bad++;} });
  for(let i=0;i<w.length;i++)for(let j=i+1;j<w.length;j++){const a=w[i],b=w[j];
    if(a.x<b.x+b.w&&b.x<a.x+a.w&&a.y<b.y+b.h&&b.y<a.y+a.h){console.log(`${p.name}: ${a.t} overlaps ${b.t}`);bad++;}}
}
console.log(bad?`${bad} problem(s)`:"OK: "+d.pages.length+" page(s)");
' my-layout.ton
```

---

## 11. Handing the file to the user

Tell them:

1. Open <https://devpossible.com/start/>
2. Press `S` (or the gear icon in edit mode) → **Data** tab → **Import**
3. Choose the `.ton` file
4. In the preview dialog, tick **Widget configurations**, and **User data** if the file
   ships bookmarks/todos/notes, and **Site settings** if it ships a theme
5. If any page name already exists, choose **Overwrite** or **Keep both** for each one
6. Confirm — the pages are added (or replaced) and the first one is opened

Everything is stored in the browser's local storage on the user's device. Nothing is
uploaded. Imported pages can be re-exported or turned into a share link from the same
Data tab.

---

## 12. Reference

| Thing | Where |
| --- | --- |
| Start Page | <https://devpossible.com/start/> |
| This guide (live) | <https://devpossible.com/start/skill/> |
| This guide (raw markdown) | <https://devpossible.com/start/skill/start-page-authoring.md> |
| Developer tools manifest | <https://devpossible.com/tools/tools-manifest.json> |
| TON format spec | <https://tonspec.com> |
| Bootstrap Icons (icon names) | <https://icons.getbootstrap.com> |
