Add README mockups, assets and background support
Revamp documentation and add visual assets + background-image support.
- Rewrite README.md and README.zh-CN.md: refreshed intro, feature tour, theme previews, acknowledgements, and minor license text change.
- Add a set of README mockups and styles (assets/readme/{mockups.css,mockups.html} and multiple PNGs) and update overview image.
- Add extension/background-image.js and background-image.test.js, and wire compressImageFileForStorage into extension/app.js (replace old readFileAsDataUrl usage and adjust related file handling). Also simplify drag preview logic by removing previous nav rect tracking/animateNavButtons call. Update extension/index.html and tests (ui-regression.test.js) accordingly.
- Add asset .DS_Store and update repository .DS_Store entry.
Binary image assets and tests were added/updated to support the new docs and background-image workflow.
@@ -2,49 +2,73 @@
|
||||
|
||||
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||
**A new tab dashboard for organizing open tabs, quick links, todos, and saved reads in one calm workspace.**
|
||||
**A calmer Chrome new tab dashboard for open tabs, quick links, saved reads, and lightweight todos.**
|
||||
|
||||
Tab Harbor turns Chrome's new tab page into something you can actually work from. Instead of opening one more empty tab and immediately getting lost again, you land in a quiet dashboard that shows what is already open, what should be saved for later, and what still needs your attention.
|
||||
Tab Harbor turns Chrome's new tab page into a place where you can keep working. You immediately see what is already open, which pages should be moved into read-later, and what still needs your attention.
|
||||
|
||||
It is built for the kind of browsing that turns into research, shipping, side quests, and thirty tabs you swear are all still useful.
|
||||
<p align="center">
|
||||
<img src="assets/readme/feature-tabs.png" alt="Tab Harbor overview" width="760">
|
||||
</p>
|
||||
|
||||
## ✨ Preview
|
||||
## ✨ Core Highlights
|
||||
|
||||
Here is the main dashboard view, with open tabs grouped into readable stacks instead of one endless strip.
|
||||
- **Tabs are automatically organized by domain.** Tab Harbor groups open pages by domain, and moves homepage-style tabs into a dedicated `Homepages` group, so you can quickly see what you are actually working on.
|
||||
- **You can still organize things around your own workflow.** When domain-based grouping is not enough, you can create manual groups, keep common quick links around, and jump back to the right section from the top icon rail.
|
||||
- **Read later and todos move into the drawer, separate from the main workspace, without needing another app.** You can search, restore, or archive them later instead of leaving everything hanging in the browser tab bar.
|
||||
- **It is not just a tab-cleaning tool.** Quick links, todos, open tabs, and saved reads all live in the same new tab page, so the next thing you need is usually right in front of you.
|
||||
- **It tries to make the workspace calmer without making the system heavier.** You can switch themes, tune transparency, set a custom background, and clean duplicate tabs with one click, while everything still stays in `chrome.storage.local` with no backend or account.
|
||||
|
||||

|
||||
## 🖼️ Feature Tour
|
||||
|
||||
And here is the side drawer for saved reads, where pages can sit for later without getting buried forever.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>Unified tab management</strong><br><br>
|
||||
<img src="assets/readme/feature-tabs.png" alt="Tabs" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>Saved reads</strong><br><br>
|
||||
<img src="assets/readme/feature-saved-drawer.png" alt="Saved reads drawer" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>Todos and quick jumping</strong><br><br>
|
||||
<img src="assets/readme/feature-todos.png" alt="Todos" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||

|
||||
### Unified tab management
|
||||
|
||||
## 🌊 Why Tab Harbor
|
||||
Tab Harbor organizes tabs more like a workspace: **domain-based groups, manual groups, quick access links, and fast jumping from the top icon rail**. If you want to clean up the browser a bit more, you can **also remove duplicate tabs with one click**.
|
||||
|
||||
Most new tab pages try to be a search box, a wallpaper, or a speed dial. Tab Harbor is closer to a lightweight control room. It keeps the messy reality of browsing visible, but turns it into something calmer: domains are grouped, homepage tabs are pulled into their own space, duplicates are easy to spot, and the “I need this, just not right now” pages finally have a home.
|
||||
### Saved reads
|
||||
|
||||
It also goes beyond tab cleanup. You can keep a row of quick links for the pages you open constantly, manage a small todo list without leaving the tab page, and build a read-later queue that feels closer to a working inbox than a pile of forgotten bookmarks.
|
||||
Pages that are not for right now can be **moved into the side drawer, then searched, restored, or archived later**, instead of living forever in the browser tab bar.
|
||||
|
||||
## 🧭 Features
|
||||
### Todos and quick jumping
|
||||
|
||||
- Group open tabs by domain so related pages stay together automatically
|
||||
- Pull homepage tabs like GitHub, YouTube, LinkedIn, X, and similar entry pages into a dedicated Homepages group
|
||||
- Jump to any open tab directly from the dashboard without opening duplicates
|
||||
- Detect duplicate pages and clean them up with one click
|
||||
- Detect duplicate Tab Harbor tabs and close the extras from the top banner
|
||||
- Save pages for later before closing them, then revisit them from the side drawer
|
||||
- Archive saved pages after you are done with them
|
||||
- Search saved pages and archived items from inside the drawer
|
||||
- Create and manage todos directly from the new tab page
|
||||
- Archive completed todos while keeping active work visible
|
||||
- Search todos and open a simple detail view for longer notes
|
||||
- Add quick links for the pages you open all the time
|
||||
- Show localhost tabs with port numbers so local projects are easier to tell apart
|
||||
- Expand large groups without losing the clean overview
|
||||
- Create manual groups and move tabs into them when domain-based grouping is not enough
|
||||
- Reorder groups from the top nav so your workflow stays in the order you want
|
||||
- Reorder saved pages and todos inside the drawer
|
||||
- Switch themes, change transparency, and use a custom background image
|
||||
- Keep everything local with no server, no account, and no external API dependency
|
||||
Tab Harbor also works as a tiny action layer: jot down todos, keep short descriptions, archive completed items, and jump back into the right group from the same page.
|
||||
|
||||
### Theme switching
|
||||
|
||||
When you want the page to feel more like your own workspace, you can **switch themes, tune transparency, and use a custom background image**.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/readme/theme-warm-neutral.png" alt="warm neutral" width="100%"></td>
|
||||
<td><img src="assets/readme/theme-soft-green.png" alt="soft green" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/readme/theme-soft-clay.png" alt="soft clay" width="100%"></td>
|
||||
<td><img src="assets/readme/theme-custom-background.png" alt="custom background" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🌊 Why It Feels Different
|
||||
|
||||
Most new tab pages try to be a search box, a wallpaper, or a speed dial. Tab Harbor is closer to a lightweight browser control room. It keeps the messy reality of browsing visible, but turns it into something calmer and more actionable.
|
||||
|
||||
That also means it is intentionally lightweight. There is no backend, no sync account, and no extra app to open. It lives exactly where the browsing chaos already happens.
|
||||
|
||||
## ⚡ Quick Use
|
||||
|
||||
@@ -73,24 +97,21 @@ It also goes beyond tab cleanup. You can keep a row of quick links for the pages
|
||||
5. Select the [`extension/`](extension/) folder
|
||||
6. Open a new tab
|
||||
|
||||
## 🪴 What It Feels Like
|
||||
|
||||
A good session in Tab Harbor feels less like “tab management” and more like having your browser desk cleaned up just enough to think again. Your working tabs stay visible, your future-reading pile moves to the side instead of disappearing, and the next action is usually one click away.
|
||||
|
||||
That is also why the extension tries to stay lightweight. There is no sync story to set up, no backend to trust, and no extra app to open. It lives where the mess already happens.
|
||||
|
||||
## 🔒 Fully Local
|
||||
|
||||
Tab Harbor runs entirely inside the extension. Open tabs are read directly from Chrome, and your saved pages, todos, quick links, theme settings, and layout preferences stay on your machine through `chrome.storage.local`.
|
||||
Tab Harbor runs entirely inside the extension. Open tabs come directly from Chrome, and saved reads, todos, quick links, theme preferences, and layout state stay on your machine through `chrome.storage.local`.
|
||||
|
||||
If you publish this repo for other people, they get the code and assets, not your personal browsing data.
|
||||
|
||||
## 🛠️ Under the Hood
|
||||
|
||||
This is a Manifest V3 Chrome extension with a plain frontend stack and no build step required to use it. The interface is driven by browser APIs, local storage, and a small amount of animation polish for actions like cleanup and closing flows.
|
||||
This is a Manifest V3 Chrome extension with a plain frontend stack and no build step required to use it. You can clone it, load it, and start using it without npm, without a dev server, and without standing up anything else.
|
||||
|
||||
That means you can clone it, load it, and start using it without npm, without a dev server, and without standing up anything else.
|
||||
## 🙏 Acknowledgements
|
||||
|
||||
- Tab Harbor is built on top of Zara's open-source project [tab-out](https://github.com/zarazhangrui/tab-out), which is the upstream repository and the starting point for this project.
|
||||
- Thanks as well to the [Linux.do community](https://linux.do) for the ideas, feedback, and the kind of maker energy that helps projects like this keep evolving.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT.
|
||||
MIT License
|
||||
|
||||
@@ -2,95 +2,115 @@
|
||||
|
||||
[English](README.md) | [简体中文](README.zh-CN.md)
|
||||
|
||||
**一个安静的新标签页工作台,把打开中的标签、快捷链接、待办和待读都放进同一个顺手的空间里。**
|
||||
**一个更安静的新标签页工作台,把打开中的标签、快捷链接、待读和轻量待办收进同一个顺手的空间里。**
|
||||
|
||||
Tab Harbor 会把 Chrome 的新标签页变成一个真正能工作的地方。你不再是一打开新标签页就继续迷路,而是会先落到一个更平静的面板里,看见自己现在到底开了什么、哪些东西应该稍后再读、还有哪些事情还没处理完。
|
||||
Tab Harbor 会把 Chrome 的新标签页变成一个可以继续工作的地方。你会先看到自己现在到底开了什么、哪些页面应该暂时收入待读、还有哪些事情还没处理完。
|
||||
|
||||
它是为那种很真实的浏览状态做的: 查资料、写东西、改代码、看文档、临时起意点开十几个页面,然后回头发现自己已经有三十个标签页,而且每一个看起来都像还不能关。
|
||||
<p align="center">
|
||||
<img src="assets/readme/feature-tabs.png" alt="Tab Harbor 总览" width="760">
|
||||
</p>
|
||||
|
||||
## ✨ 效果展示
|
||||
|
||||
这是主页主视图,打开中的标签页会按站点整理成更容易扫一眼理解的几组,而不是继续挤在浏览器顶部那一条里。
|
||||
## ✨ 核心亮点
|
||||
|
||||

|
||||
- **标签页按域名自动整理。** Tab Harbor 会按域名整理打开中的页面,把首页型标签单独收到 `Homepages` 分组里,让你先看清自己到底在处理什么。
|
||||
- **自动分组之外,你也还能按自己的工作流继续整理。** 当域名分组不够用时,你可以创建手动分组、保留常用的 quick links,并通过顶部图标很快跳回需要的区域。
|
||||
- **“稍后读”和“待办”会收入抽屉,和工作区区分开,且无需打开额外软件。**搜索、恢复或归档,而不是一直挂在浏览器顶部占着位置。
|
||||
- **它也不只是一个整理标签页的工具。** Quick links、Todos、打开中的标签页和待读内容都在同一个新标签页里,下一步要做什么通常就在眼前。
|
||||
- **它尽量让工作台更安静,但不让系统更重。** 你可以切换主题、调透明度、换背景图,也可以一键清理重复标签页;同时所有数据都还是保存在 `chrome.storage.local`,不需要服务器也不需要账号。
|
||||
|
||||
这是右侧抽屉里的待读区域,那些“现在不看,但之后一定会回来的页面”终于不用再变成失踪书签了。
|
||||
## 🖼️ 功能展示
|
||||
|
||||

|
||||
<table>
|
||||
<tr>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>标签页统一管理</strong><br><br>
|
||||
<img src="assets/readme/feature-tabs.png" alt="标签页" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>待读处理</strong><br><br>
|
||||
<img src="assets/readme/feature-saved-drawer.png" alt="待读抽屉" width="100%">
|
||||
</td>
|
||||
<td width="33.33%" valign="top">
|
||||
<strong>待办和跳转</strong><br><br>
|
||||
<img src="assets/readme/feature-todos.png" alt="待办" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🌊 为什么会想做它
|
||||
### 标签页统一管理
|
||||
|
||||
很多新标签页产品想做的是搜索框、壁纸页,或者一个漂亮一点的快捷方式面板。Tab Harbor 更像一个很轻的浏览器控制台。它不会假装你没有开很多标签页,而是承认这种混乱就是现实,然后把它整理成更能工作的样子: 域名自动分组、首页类页面单独拎出来、重复页一眼能看见、那些“不是现在,但之后要回来看”的页面也终于有了地方放。
|
||||
Tab Harbor 会把标签页整理成更像工作区的结构:**按域名分组、支持手动分组、保留快捷入口,并且能从顶部图标快速跳回对应区域**;想把浏览器收拾利落一点时,**也可以一键清理重复标签页**。
|
||||
|
||||
它也不只是一个“整理标签页”的工具。你可以在顶部放常用的 quick links,把固定会开的页面保留下来;也可以在新标签页里直接记待办,不用再多开一个工具;还可以把暂时不看的页面放进 read-later 队列里,让它更像一个工作收件箱,而不是一个永远不会翻的收藏夹。
|
||||
### 待读处理
|
||||
|
||||
## 🧭 功能
|
||||
那些“现在先不看,但之后一定要回来”的页面,可以**先收入右侧抽屉,之后再搜索、恢复或归档**,而不是永远挂在浏览器顶部。
|
||||
|
||||
- 按域名自动整理打开中的标签页,让相关页面自然待在一起
|
||||
- 把 GitHub、YouTube、LinkedIn、X 这类首页型页面单独收进 Homepages 分组
|
||||
- 直接从面板跳回任何已打开的标签页,避免重复打开
|
||||
- 识别重复页面,并一键清理多余副本
|
||||
- 识别重复打开的 Tab Harbor 标签页,并在顶部提示条里关闭多余页面
|
||||
- 在关闭前把页面保存到待读区,之后再从侧边抽屉里慢慢处理
|
||||
- 读完之后把保存页归档,而不是硬删掉
|
||||
- 在保存页和归档里直接搜索,回找内容更快
|
||||
- 直接在新标签页里创建和管理 todos
|
||||
- 完成的 todo 会进入归档,活跃事项继续留在前面
|
||||
- 支持搜索 todo,也可以打开简单的详情视图写更长一点的说明
|
||||
- 添加 quick links,把自己最常开的页面固定在顶部
|
||||
- 对 localhost 页面显示端口号,更容易区分本地项目
|
||||
- 分组太大时可以展开,不会破坏整体概览
|
||||
- 支持创建手动分组,在域名分组不够用时自己整理结构
|
||||
- 支持从顶部导航重排分组顺序,让工作流顺手一点
|
||||
- 支持在抽屉里重排保存页和 todos
|
||||
- 支持切换主题、调整透明度、使用自定义背景图
|
||||
- 全部数据本地保存,不需要服务器、不需要账号、也不依赖外部 API
|
||||
### 待办和跳转
|
||||
|
||||
除了整理标签页,你还可以在这里顺手记下待办、补一点简短说明、归档完成项,并从顶部图标快速回到当前任务相关的分组。
|
||||
|
||||
### 切换主题
|
||||
|
||||
想让它更像你自己的工作台时,可以**切换主题、调透明度、换背景图**。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/readme/theme-warm-neutral.png" alt="warm neutral" width="100%"></td>
|
||||
<td><img src="assets/readme/theme-soft-green.png" alt="soft green" width="100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/readme/theme-soft-clay.png" alt="soft clay" width="100%"></td>
|
||||
<td><img src="assets/readme/theme-custom-background.png" alt="custom background" width="100%"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 🌊 为什么它用起来不一样
|
||||
|
||||
很多新标签页产品想做的是搜索框、壁纸页,或者更漂亮一点的快捷方式面板。Tab Harbor 更像一个很轻的浏览器控制台。它不会假装你没有开很多标签页,而是承认这种混乱就是现实,然后把它整理成更能工作的样子。
|
||||
|
||||
这也是它为什么尽量保持轻。没有后端,没有同步账号,也不需要你再开一个单独应用。所有混乱本来就发生在浏览器里,那它就直接在那里帮你收拾。
|
||||
|
||||
## ⚡ 快速使用
|
||||
|
||||
### 用 coding agent 安装
|
||||
|
||||
1. 把这个仓库地址给你的 coding agent:
|
||||
1. 把这个仓库地址给你的 coding agent:
|
||||
|
||||
```text
|
||||
https://github.com/V-IOLE-T/tab-harbor
|
||||
```
|
||||
|
||||
2. 让它帮你安装扩展
|
||||
3. 在 Chrome 里打开一个新标签页
|
||||
|
||||
### 手动安装
|
||||
|
||||
1. 克隆仓库:
|
||||
1. 克隆仓库:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/V-IOLE-T/tab-harbor.git
|
||||
```
|
||||
|
||||
2. 打开 `chrome://extensions`
|
||||
3. 开启 **Developer mode**
|
||||
4. 点击 **Load unpacked**
|
||||
5. 选择 [`extension/`](extension/) 文件夹
|
||||
6. 打开一个新标签页
|
||||
|
||||
## 🪴 用起来是什么感觉
|
||||
|
||||
一次比较顺的 Tab Harbor 会话,不太像“我在管理标签页”,更像“我终于把浏览器桌面收拾到可以继续思考了”。正在处理的页面还看得见,以后再看的内容被挪到了侧边,不会凭空蒸发,下一步要点什么通常也就在眼前。
|
||||
|
||||
这也是它为什么尽量保持轻。它没有一整套要配置的同步系统,没有必须信任的后端,也不需要你再开一个单独应用。所有混乱本来就发生在浏览器里,那它就应该直接在那里帮你收拾。
|
||||
|
||||
## 🔒 完全本地
|
||||
|
||||
Tab Harbor 完全运行在扩展内部。打开中的标签页直接来自 Chrome,保存页、todos、quick links、主题设置和布局偏好都留在你自己的机器上,通过 `chrome.storage.local` 保存。
|
||||
Tab Harbor 完全运行在扩展内部。打开中的标签页直接来自 Chrome,保存页、Todos、Quick links、主题偏好和布局状态都留在你自己的机器上,通过 `chrome.storage.local` 保存。
|
||||
|
||||
如果你把这个仓库发到 GitHub 给别人用,他们拿到的是代码和资源文件,不会带上你的个人浏览数据。
|
||||
|
||||
## 🛠️ 底层上它很简单
|
||||
## 🛠️ 底层
|
||||
|
||||
这是一个 Manifest V3 的 Chrome 扩展,前端结构很轻,使用时也不需要 build step。界面主要依赖浏览器 API、本地存储,以及少量让交互更舒服的动画细节,比如清理、关闭和抽屉切换。
|
||||
这是一个 Manifest V3 的 Chrome 扩展,前端结构很轻,使用时也不需要 build step。你可以直接 clone、直接加载、直接开始用,不需要 npm,不需要 dev server,也不需要先把别的东西跑起来。
|
||||
|
||||
这也意味着你可以直接 clone、直接加载、直接开始用,不需要 npm,不需要 dev server,也不需要先把别的东西跑起来。
|
||||
## 🙏 致谢
|
||||
|
||||
- Tab Harbor 基于 Zara 的开源项目 [tab-out](https://github.com/zarazhangrui/tab-out) 继续发展,它也是本项目的上游仓库和最初出发点。
|
||||
- 感谢 [Linux.do 社区](https://linux.do) 提供的灵感、反馈和很有生命力的开源氛围,让这个项目能持续往前长。
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT。
|
||||
MIT License
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,463 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--ink: #1f1914;
|
||||
--paper: #f6f0e6;
|
||||
--paper-strong: #fffaf3;
|
||||
--line: rgba(109, 84, 51, 0.16);
|
||||
--accent: #c6723e;
|
||||
--accent-soft: rgba(198, 114, 62, 0.12);
|
||||
--sage: #5f7761;
|
||||
--mist: #63768a;
|
||||
--rose: #b46a70;
|
||||
--shadow: 0 24px 60px rgba(69, 48, 22, 0.12);
|
||||
--shadow-soft: 0 12px 24px rgba(69, 48, 22, 0.08);
|
||||
font-family: "SF Pro Text", "DM Sans", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1440px;
|
||||
height: 860px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(198, 114, 62, 0.16), transparent 30%),
|
||||
radial-gradient(circle at bottom right, rgba(95, 119, 97, 0.15), transparent 28%),
|
||||
linear-gradient(180deg, #f9f4ec 0%, #f3ece0 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.stage,
|
||||
.scene {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.scene {
|
||||
display: none;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
html[data-scene="overview"] .scene[data-scene="overview"],
|
||||
html[data-scene="saved"] .scene[data-scene="saved"],
|
||||
html[data-scene="todos"] .scene[data-scene="todos"],
|
||||
html[data-scene="themes"] .scene[data-scene="themes"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.window {
|
||||
height: 100%;
|
||||
border: 1px solid rgba(151, 121, 83, 0.14);
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 250, 243, 0.76);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px 24px 28px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(98, 129, 105, 0.12);
|
||||
border: 1px solid rgba(98, 129, 105, 0.18);
|
||||
color: #38533d;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.banner.warning {
|
||||
background: rgba(198, 114, 62, 0.12);
|
||||
border-color: rgba(198, 114, 62, 0.2);
|
||||
color: #7a4422;
|
||||
}
|
||||
|
||||
.banner-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.banner-action {
|
||||
margin-left: auto;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.hero.compact {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(70, 53, 35, 0.58);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-family: "Iowan Old Style", "Georgia", serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
font-size: 52px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.subcopy {
|
||||
max-width: 760px;
|
||||
margin: 16px 0 0;
|
||||
color: rgba(48, 37, 26, 0.72);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.search,
|
||||
.theme-trigger,
|
||||
.drawer-tabs {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
padding: 0 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 251, 245, 0.82);
|
||||
box-shadow: var(--shadow-soft);
|
||||
color: rgba(54, 41, 27, 0.7);
|
||||
}
|
||||
|
||||
.search {
|
||||
min-width: 340px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 92px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(111, 88, 58, 0.14);
|
||||
background: rgba(255, 251, 246, 0.84);
|
||||
box-shadow: var(--shadow-soft);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shortcut.github { color: #7c4a2d; }
|
||||
.shortcut.docs { color: #4d6a7f; }
|
||||
.shortcut.local { color: #456a52; }
|
||||
.shortcut.read { color: #8a5260; }
|
||||
.shortcut.add { min-width: 54px; }
|
||||
|
||||
.icon-rail {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.icon-rail.left {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.rail-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 56px;
|
||||
height: 44px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 248, 240, 0.8);
|
||||
border: 1px solid rgba(111, 88, 58, 0.14);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(65, 49, 31, 0.76);
|
||||
}
|
||||
|
||||
.rail-pill.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: rgba(198, 114, 62, 0.24);
|
||||
color: #874b27;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.workspace-card,
|
||||
.drawer,
|
||||
.theme-panel {
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(111, 88, 58, 0.14);
|
||||
background: rgba(255, 251, 246, 0.86);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
padding: 20px 20px 18px;
|
||||
}
|
||||
|
||||
.workspace-card.accent {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(198, 114, 62, 0.11) 0%, rgba(255, 251, 246, 0.92) 100%);
|
||||
}
|
||||
|
||||
.card-head,
|
||||
.drawer-header,
|
||||
.theme-header,
|
||||
.archive-row,
|
||||
.range-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(80, 60, 40, 0.52);
|
||||
}
|
||||
|
||||
.badge,
|
||||
.mini-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(198, 114, 62, 0.1);
|
||||
color: #8a4d29;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(244, 237, 226, 0.92);
|
||||
color: rgba(58, 44, 29, 0.76);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.split-layout,
|
||||
.theme-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 0.9fr);
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
height: calc(100% - 132px);
|
||||
}
|
||||
|
||||
.left-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.drawer,
|
||||
.theme-panel {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.list,
|
||||
.todo-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.list-item,
|
||||
.todo-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(243, 235, 224, 0.68);
|
||||
}
|
||||
|
||||
.list-item p,
|
||||
.todo-row p {
|
||||
margin: 6px 0 0;
|
||||
color: rgba(67, 51, 34, 0.66);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.checkbox,
|
||||
.todo-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(120, 94, 61, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.todo-check.checked {
|
||||
background: var(--sage);
|
||||
border-color: var(--sage);
|
||||
box-shadow: inset 0 0 0 4px rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.archive {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(111, 88, 58, 0.1);
|
||||
color: rgba(64, 49, 32, 0.68);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drawer-tabs {
|
||||
padding: 6px;
|
||||
min-height: auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-tab {
|
||||
padding: 10px 16px;
|
||||
border-radius: 14px;
|
||||
color: rgba(67, 51, 34, 0.66);
|
||||
}
|
||||
|
||||
.drawer-tab.active {
|
||||
background: rgba(198, 114, 62, 0.12);
|
||||
color: #8a4d29;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-layout {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(111, 88, 58, 0.14);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.swatch.paper { background: linear-gradient(135deg, #fff8ef, #d99865); }
|
||||
.swatch.sage { background: linear-gradient(135deg, #edf5e8, #7c9b81); }
|
||||
.swatch.mist { background: linear-gradient(135deg, #eef3f8, #7d95ab); }
|
||||
.swatch.blush { background: linear-gradient(135deg, #f8efef, #c78a8f); }
|
||||
|
||||
.swatch.active {
|
||||
outline: 3px solid rgba(198, 114, 62, 0.24);
|
||||
}
|
||||
|
||||
.range-block {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: rgba(244, 237, 226, 0.72);
|
||||
}
|
||||
|
||||
.range-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
margin-top: 14px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(198, 114, 62, 0.28), rgba(198, 114, 62, 0.1));
|
||||
}
|
||||
|
||||
.range-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 32%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: #9f5c33;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 6px 16px rgba(94, 53, 25, 0.24);
|
||||
}
|
||||
|
||||
.theme-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
height: 160px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(198, 114, 62, 0.16), transparent 34%),
|
||||
rgba(255, 249, 242, 0.92);
|
||||
border: 1px solid rgba(111, 88, 58, 0.12);
|
||||
}
|
||||
|
||||
.preview-card.warm {
|
||||
background:
|
||||
radial-gradient(circle at bottom right, rgba(95, 119, 97, 0.18), transparent 34%),
|
||||
rgba(250, 244, 236, 0.92);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tab Harbor README Mockups</title>
|
||||
<link rel="stylesheet" href="./mockups.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="stage">
|
||||
<section class="scene" data-scene="overview">
|
||||
<div class="window">
|
||||
<div class="banner">
|
||||
<span class="banner-dot"></span>
|
||||
Domain-grouped tabs, custom groups, and one-click jumps from the top icon rail.
|
||||
</div>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Tab Harbor</p>
|
||||
<h1>Open tabs, sorted into calm workspaces.</h1>
|
||||
<p class="subcopy">
|
||||
Group by domain, keep quick links nearby, and give homepage tabs their own lane instead of letting
|
||||
everything collapse into one endless browser strip.
|
||||
</p>
|
||||
</div>
|
||||
<div class="search">
|
||||
<span class="search-icon">⌕</span>
|
||||
<span>Search with your default engine...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shortcut-row">
|
||||
<span class="shortcut github">GitHub</span>
|
||||
<span class="shortcut docs">Docs</span>
|
||||
<span class="shortcut local">Localhost</span>
|
||||
<span class="shortcut read">Read Later</span>
|
||||
<span class="shortcut add">+</span>
|
||||
</div>
|
||||
|
||||
<div class="icon-rail">
|
||||
<span class="rail-pill active">GH</span>
|
||||
<span class="rail-pill">DOC</span>
|
||||
<span class="rail-pill">LD</span>
|
||||
<span class="rail-pill">LP</span>
|
||||
<span class="rail-pill">HP</span>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">github.com</p>
|
||||
<h2>5 open tabs</h2>
|
||||
</div>
|
||||
<span class="badge">Auto group</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">repo / issues</span>
|
||||
<span class="chip">README polish</span>
|
||||
<span class="chip">PR diff</span>
|
||||
<span class="chip">Actions</span>
|
||||
<span class="chip">notifications</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Docs</p>
|
||||
<h2>4 open tabs</h2>
|
||||
</div>
|
||||
<span class="badge">Jump fast</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Chrome extension API</span>
|
||||
<span class="chip">Manifest V3</span>
|
||||
<span class="chip">CDP screenshot notes</span>
|
||||
<span class="chip">Theme reference</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card accent">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Launch Prep</p>
|
||||
<h2>3 pinned tabs</h2>
|
||||
</div>
|
||||
<span class="badge">Custom group</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">README checklist</span>
|
||||
<span class="chip">release notes</span>
|
||||
<span class="chip">community post draft</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Homepages</p>
|
||||
<h2>4 landing tabs</h2>
|
||||
</div>
|
||||
<span class="badge">Clean top bar</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">YouTube</span>
|
||||
<span class="chip">LinkedIn</span>
|
||||
<span class="chip">X</span>
|
||||
<span class="chip">Gmail</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="saved">
|
||||
<div class="window">
|
||||
<header class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Saved For Later</p>
|
||||
<h1>Turn “not now” tabs into an inbox.</h1>
|
||||
</div>
|
||||
<div class="drawer-tabs">
|
||||
<span class="drawer-tab active">Saved</span>
|
||||
<span class="drawer-tab">Todos</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="split-layout">
|
||||
<div class="left-stack">
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">linux.do</p>
|
||||
<h2>3 open tabs</h2>
|
||||
</div>
|
||||
<span class="badge">Community</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Extension feedback</span>
|
||||
<span class="chip">Showcase thread</span>
|
||||
<span class="chip">Launch notes</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">localhost</p>
|
||||
<h2>2 active tabs</h2>
|
||||
</div>
|
||||
<span class="badge">:3000 / :5173</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">preview build</span>
|
||||
<span class="chip">extension sandbox</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="drawer">
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="label">Saved for later</p>
|
||||
<h2>5 waiting reads</h2>
|
||||
</div>
|
||||
<span class="mini-action">Search</span>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<div class="list-item">
|
||||
<div class="checkbox"></div>
|
||||
<div>
|
||||
<strong>Designing calm productivity surfaces</strong>
|
||||
<p>Saved 2h ago · Medium</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="checkbox"></div>
|
||||
<div>
|
||||
<strong>Chrome tabs API reference</strong>
|
||||
<p>Saved yesterday · developer.chrome.com</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="checkbox"></div>
|
||||
<div>
|
||||
<strong>Tab-out inspiration notes</strong>
|
||||
<p>Saved yesterday · GitHub</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item">
|
||||
<div class="checkbox"></div>
|
||||
<div>
|
||||
<strong>README screenshot checklist</strong>
|
||||
<p>Saved 3m ago · local note</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive">
|
||||
<div class="archive-row">
|
||||
<span>Archive</span>
|
||||
<span>12 done</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="todos">
|
||||
<div class="window">
|
||||
<header class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Tasks In Context</p>
|
||||
<h1>Keep todos where the tabs already live.</h1>
|
||||
</div>
|
||||
<div class="drawer-tabs">
|
||||
<span class="drawer-tab">Saved</span>
|
||||
<span class="drawer-tab active">Todos</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="split-layout">
|
||||
<div class="left-stack">
|
||||
<article class="workspace-card accent">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Top icon rail</p>
|
||||
<h2>One click to jump</h2>
|
||||
</div>
|
||||
<span class="badge">Focused navigation</span>
|
||||
</div>
|
||||
<div class="icon-rail left">
|
||||
<span class="rail-pill active">WK</span>
|
||||
<span class="rail-pill">DOC</span>
|
||||
<span class="rail-pill">GH</span>
|
||||
<span class="rail-pill">RD</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Work</span>
|
||||
<span class="chip">Docs</span>
|
||||
<span class="chip">GitHub</span>
|
||||
<span class="chip">Reading</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Quick capture</p>
|
||||
<h2>From a tab to a task</h2>
|
||||
</div>
|
||||
<span class="badge">No context switch</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Write changelog</span>
|
||||
<span class="chip">Trim duplicates</span>
|
||||
<span class="chip">Reply in Linux.do</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside class="drawer">
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
<p class="label">Todos</p>
|
||||
<h2>4 active tasks</h2>
|
||||
</div>
|
||||
<span class="mini-action">New</span>
|
||||
</div>
|
||||
|
||||
<div class="todo-card">
|
||||
<div class="todo-row">
|
||||
<span class="todo-check checked"></span>
|
||||
<div>
|
||||
<strong>Refresh README screenshots</strong>
|
||||
<p>Feature showcase · in progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="todo-row">
|
||||
<span class="todo-check"></span>
|
||||
<div>
|
||||
<strong>Highlight core selling points</strong>
|
||||
<p>Domain groups, custom groups, top rail jump</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="todo-row">
|
||||
<span class="todo-check"></span>
|
||||
<div>
|
||||
<strong>Add upstream acknowledgement</strong>
|
||||
<p>Link to Zara's repo and Linux.do community</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive">
|
||||
<div class="archive-row">
|
||||
<span>Archive</span>
|
||||
<span>7 completed</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="scene" data-scene="themes">
|
||||
<div class="window">
|
||||
<div class="banner warning">
|
||||
<span class="banner-dot"></span>
|
||||
You have 2 Tab Harbor tabs open. Close extras and keep just this workspace.
|
||||
<span class="banner-action">Close extras</span>
|
||||
</div>
|
||||
|
||||
<header class="hero compact">
|
||||
<div>
|
||||
<p class="eyebrow">Theme Controls</p>
|
||||
<h1>Switch themes, tune transparency, keep the browser tidy.</h1>
|
||||
</div>
|
||||
<div class="theme-trigger">Theme ▾</div>
|
||||
</header>
|
||||
|
||||
<div class="theme-layout">
|
||||
<article class="workspace-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<p class="label">Cleanup actions</p>
|
||||
<h2>Fast duplicate control</h2>
|
||||
</div>
|
||||
<span class="badge">One click</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Close duplicate tabs</span>
|
||||
<span class="chip">Close all in group</span>
|
||||
<span class="chip">Keep one copy</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="theme-panel">
|
||||
<div class="theme-header">
|
||||
<div>
|
||||
<p class="label">Appearance</p>
|
||||
<h2>Paper themes</h2>
|
||||
</div>
|
||||
<span class="mini-action">Upload background</span>
|
||||
</div>
|
||||
|
||||
<div class="swatches">
|
||||
<span class="swatch paper active"></span>
|
||||
<span class="swatch sage"></span>
|
||||
<span class="swatch mist"></span>
|
||||
<span class="swatch blush"></span>
|
||||
</div>
|
||||
|
||||
<div class="range-block">
|
||||
<div class="range-row">
|
||||
<span>Surface transparency</span>
|
||||
<strong>14%</strong>
|
||||
</div>
|
||||
<div class="range-track">
|
||||
<span class="range-thumb"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="theme-preview">
|
||||
<div class="preview-card"></div>
|
||||
<div class="preview-card warm"></div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const scene = new URLSearchParams(window.location.search).get('scene') || 'overview';
|
||||
document.documentElement.dataset.scene = scene;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 522 KiB After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -46,6 +46,10 @@ const {
|
||||
reorderSubsetByIds,
|
||||
} = globalThis.TabOutListOrder || {};
|
||||
|
||||
const {
|
||||
compressImageFileForStorage,
|
||||
} = globalThis.TabOutBackgroundImage || {};
|
||||
|
||||
const {
|
||||
completeTodo,
|
||||
clearArchivedTodos,
|
||||
@@ -410,15 +414,6 @@ async function saveThemePreferences(nextPreferences) {
|
||||
return themePreferences;
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = () => reject(reader.error || new Error('Could not read file'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function reorderVisibleItemsByIds(items, orderIds, includeItem) {
|
||||
if (reorderSubsetByIds) {
|
||||
return reorderSubsetByIds(items, orderIds, includeItem);
|
||||
@@ -965,11 +960,6 @@ function previewDraggedOrder(clientX) {
|
||||
if (!navListEl || !draggedGroupId) return;
|
||||
|
||||
const placeholder = ensureDragPlaceholder();
|
||||
const previousNavRects = new Map();
|
||||
navListEl.querySelectorAll('.group-nav-button:not(.is-dragging)').forEach(button => {
|
||||
previousNavRects.set(button.dataset.groupId || '', button.getBoundingClientRect());
|
||||
});
|
||||
|
||||
const buttons = [...navListEl.querySelectorAll('.group-nav-button:not(.is-dragging)')];
|
||||
let insertBeforeButton = null;
|
||||
|
||||
@@ -987,8 +977,6 @@ function previewDraggedOrder(clientX) {
|
||||
navListEl.appendChild(placeholder);
|
||||
}
|
||||
|
||||
animateNavButtons(navListEl, previousNavRects);
|
||||
|
||||
const previewOrderKeys = [...navListEl.children]
|
||||
.map(node => {
|
||||
if (node === placeholder) return draggedGroupId;
|
||||
@@ -3530,7 +3518,10 @@ document.addEventListener('change', async (e) => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const customBackground = await readFileAsDataUrl(file);
|
||||
if (!compressImageFileForStorage) {
|
||||
throw new Error('Background compression is unavailable');
|
||||
}
|
||||
const customBackground = await compressImageFileForStorage(file);
|
||||
await saveThemePreferences({ customBackground });
|
||||
themeMenuOpen = false;
|
||||
renderThemeMenu();
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
|
||||
(function attachBackgroundImage(globalScope) {
|
||||
const MAX_BACKGROUND_EDGE = 1920;
|
||||
const MAX_BACKGROUND_BYTES = 900 * 1024;
|
||||
const BACKGROUND_IMAGE_TYPE = 'image/webp';
|
||||
const BACKGROUND_IMAGE_QUALITIES = [0.82, 0.74, 0.66, 0.58, 0.5];
|
||||
const BACKGROUND_IMAGE_EDGE_STEPS = [1, 0.88, 0.76, 0.64];
|
||||
|
||||
function constrainImageDimensions({ width, height, maxEdge = MAX_BACKGROUND_EDGE }) {
|
||||
const safeWidth = Math.max(1, Math.round(Number(width) || 0));
|
||||
const safeHeight = Math.max(1, Math.round(Number(height) || 0));
|
||||
const safeMaxEdge = Math.max(1, Math.round(Number(maxEdge) || MAX_BACKGROUND_EDGE));
|
||||
const largestEdge = Math.max(safeWidth, safeHeight);
|
||||
|
||||
if (largestEdge <= safeMaxEdge) {
|
||||
return { width: safeWidth, height: safeHeight };
|
||||
}
|
||||
|
||||
const scale = safeMaxEdge / largestEdge;
|
||||
return {
|
||||
width: Math.max(1, Math.round(safeWidth * scale)),
|
||||
height: Math.max(1, Math.round(safeHeight * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function estimateDataUrlBytes(dataUrl) {
|
||||
const value = String(dataUrl || '');
|
||||
const marker = 'base64,';
|
||||
const markerIndex = value.indexOf(marker);
|
||||
if (markerIndex === -1) return 0;
|
||||
|
||||
const payload = value.slice(markerIndex + marker.length);
|
||||
const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0;
|
||||
return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
function loadImageFromFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
reject(new Error('Could not read image'));
|
||||
};
|
||||
image.src = objectUrl;
|
||||
});
|
||||
}
|
||||
|
||||
async function compressImageFileForStorage(file, options = {}) {
|
||||
if (!file || !String(file.type || '').startsWith('image/')) {
|
||||
throw new Error('Please choose an image file');
|
||||
}
|
||||
|
||||
const maxBytes = Math.max(32 * 1024, Number(options.maxBytes) || MAX_BACKGROUND_BYTES);
|
||||
const maxEdge = Math.max(320, Number(options.maxEdge) || MAX_BACKGROUND_EDGE);
|
||||
const image = await loadImageFromFile(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d', { alpha: false });
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not prepare image compression');
|
||||
}
|
||||
|
||||
let bestDataUrl = '';
|
||||
let bestBytes = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const edgeStep of BACKGROUND_IMAGE_EDGE_STEPS) {
|
||||
const dimensions = constrainImageDimensions({
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
maxEdge: Math.max(320, Math.round(maxEdge * edgeStep)),
|
||||
});
|
||||
|
||||
canvas.width = dimensions.width;
|
||||
canvas.height = dimensions.height;
|
||||
context.clearRect(0, 0, dimensions.width, dimensions.height);
|
||||
context.fillStyle = '#f6f0e6';
|
||||
context.fillRect(0, 0, dimensions.width, dimensions.height);
|
||||
context.drawImage(image, 0, 0, dimensions.width, dimensions.height);
|
||||
|
||||
for (const quality of BACKGROUND_IMAGE_QUALITIES) {
|
||||
const dataUrl = canvas.toDataURL(BACKGROUND_IMAGE_TYPE, quality);
|
||||
const bytes = estimateDataUrlBytes(dataUrl);
|
||||
if (bytes < bestBytes) {
|
||||
bestBytes = bytes;
|
||||
bestDataUrl = dataUrl;
|
||||
}
|
||||
if (bytes <= maxBytes) {
|
||||
return dataUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestDataUrl) {
|
||||
throw new Error(`Image is too large even after compression (${Math.ceil(bestBytes / 1024)} KB)`);
|
||||
}
|
||||
|
||||
throw new Error('Could not compress image');
|
||||
}
|
||||
|
||||
const api = {
|
||||
BACKGROUND_IMAGE_TYPE,
|
||||
MAX_BACKGROUND_BYTES,
|
||||
MAX_BACKGROUND_EDGE,
|
||||
compressImageFileForStorage,
|
||||
constrainImageDimensions,
|
||||
estimateDataUrlBytes,
|
||||
};
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = api;
|
||||
}
|
||||
|
||||
globalScope.TabOutBackgroundImage = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : window);
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
constrainImageDimensions,
|
||||
estimateDataUrlBytes,
|
||||
MAX_BACKGROUND_EDGE,
|
||||
} = require('./background-image.js');
|
||||
|
||||
test('constrainImageDimensions keeps smaller images unchanged', () => {
|
||||
assert.deepEqual(
|
||||
constrainImageDimensions({ width: 1200, height: 800, maxEdge: MAX_BACKGROUND_EDGE }),
|
||||
{ width: 1200, height: 800 }
|
||||
);
|
||||
});
|
||||
|
||||
test('constrainImageDimensions scales landscape images down to the max edge', () => {
|
||||
assert.deepEqual(
|
||||
constrainImageDimensions({ width: 4000, height: 2000, maxEdge: 1920 }),
|
||||
{ width: 1920, height: 960 }
|
||||
);
|
||||
});
|
||||
|
||||
test('constrainImageDimensions scales portrait images down to the max edge', () => {
|
||||
assert.deepEqual(
|
||||
constrainImageDimensions({ width: 1800, height: 3600, maxEdge: 1600 }),
|
||||
{ width: 800, height: 1600 }
|
||||
);
|
||||
});
|
||||
|
||||
test('estimateDataUrlBytes converts base64 payload length to bytes', () => {
|
||||
const payload = Buffer.from('tab-harbor-background').toString('base64');
|
||||
const dataUrl = `data:image/jpeg;base64,${payload}`;
|
||||
|
||||
assert.equal(
|
||||
estimateDataUrlBytes(dataUrl),
|
||||
Buffer.byteLength('tab-harbor-background')
|
||||
);
|
||||
});
|
||||
@@ -245,6 +245,7 @@
|
||||
<script src="deferred-trigger-position.js"></script>
|
||||
<script src="todos-store.js"></script>
|
||||
<script src="list-order.js"></script>
|
||||
<script src="background-image.js"></script>
|
||||
|
||||
<!-- Main dashboard logic — loads last so the DOM is ready -->
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -232,6 +232,9 @@ test('theme menu styles and custom background layer are defined', () => {
|
||||
assert.match(css, /\.action-btn\.close-tabs:hover\s*\{[\s\S]*background:\s*var\(--workspace-chip-bg-strong\);[\s\S]*border-color:\s*var\(--workspace-accent-border\);/);
|
||||
assert.match(css, /\.deferred-shell\s*\{[\s\S]*var\(--panel-surface-opacity\)/);
|
||||
assert.match(css, /\.todo-detail-card\s*\{[\s\S]*var\(--panel-card-opacity\)/);
|
||||
assert.match(appJs, /compressImageFileForStorage/);
|
||||
assert.doesNotMatch(appJs, /readFileAsDataUrl/);
|
||||
assert.match(html, /<script src="background-image\.js"><\/script>/);
|
||||
});
|
||||
|
||||
test('quick tabs area renders shortcut cards and add button hooks', () => {
|
||||
|
||||